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..c89d35f 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) @@ -32,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 @@ -56,13 +66,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 }; }