Codec.checkProofOfWork compares the block hash against the expanded target and nothing else:
checkProofOfWork(header) {
return BigInt('0x' + this.blockHash(header)) <= this.expandCompact(header.bits);
}
Bitcoin Core's CheckProofOfWork applies four range checks to the target before comparing:
bnTarget.SetCompact(nBits, &fNegative, &fOverflow);
if (fNegative || bnTarget == 0 || fOverflow || bnTarget > UintToArith256(params.powLimit))
return false;
None are present. The two that matter:
No powLimit bound. Any nBits claiming an easier target than the network minimum is accepted. HeaderEngine already holds this.powLimit and uses it in retarget() — it is simply never applied to the work check. SpvEngine does not load it at all.
No overflow detection. expandCompact masks the sign bit off (bits & 0x007fffff) and, being BigInt-based, cannot overflow the way arith_uint256 does. nBits = 0xff123456 produces a target of ~2040 bits, which Core rejects via fOverflow:
OVERFLOW bits=0xff123456 target=12345600000000000000...(510 hex digits)
The negative-flag and zero-target cases happen to return false anyway through the arithmetic, so they are not exploitable — but they are also not checked.
Impact is worst in the SPV path
btc:rule-spv-pow binds directly to checkProofOfWork, and the SPV ruleset has no difficulty rule to compensate. In validateChain, btc:rule-header-difficulty masks the problem when prev and epochFirst context is available, and returns null when it is not.
So a forged inclusion proof costs nothing. Fabricating a "block" containing a transaction that was never mined, with nonce: 1 and no work performed:
forged block hash : 55026a669e2f1dcca0dab82e8c18071aeadba6d381b94ae73211a166e9315760
work performed : none (nonce = 1)
true btc:rule-spv-pow
true btc:rule-spv-tree
true btc:rule-spv-merkle-root
true btc:rule-spv-inclusion
SPV VERDICT ok : true
claimed inclusion : [ 'babababababababababababababababababababababababababababababababa' ]
Reproduction:
import { createKernel } from '@bitcoin-kernel/kernel';
const k = createKernel();
const fakeTxid = 'ba'.repeat(32);
const header = {
version: 1, prevBlockHash: '0'.repeat(64), merkleRoot: fakeTxid,
time: 1700000000, bits: 0x207fffff, nonce: 1,
};
const merkleBlock = { header, txCount: 1, hashes: [fakeTxid], flags: '01' };
k.spv.verify(merkleBlock, { txid: fakeTxid }).ok; // => true
bits: 0x207fffff is the regtest-style target, evaluated by a mainnet-configured kernel.
SPV correctly requires the caller to anchor the header to a known chain, and a caller that does so is protected. But SpvEngine.verify() returns ok: true with no indication that an external check is required, and a rule named btc:rule-spv-pow that provides no work guarantee is worse than no rule — it reads as assurance.
Suggested fix
Give Codec the network's powLimit (or pass a target bound into checkProofOfWork), and reject when the expanded target is zero, exceeds 256 bits, or exceeds powLimit. Surfacing the negative flag would complete the parity with Core.
Affects every vendored copy — kernel, browser-node, health, forks, mempool — since codec.js is byte-identical across all five.
Codec.checkProofOfWorkcompares the block hash against the expanded target and nothing else:Bitcoin Core's
CheckProofOfWorkapplies four range checks to the target before comparing:None are present. The two that matter:
No
powLimitbound. AnynBitsclaiming an easier target than the network minimum is accepted.HeaderEnginealready holdsthis.powLimitand uses it inretarget()— it is simply never applied to the work check.SpvEnginedoes not load it at all.No overflow detection.
expandCompactmasks the sign bit off (bits & 0x007fffff) and, being BigInt-based, cannot overflow the wayarith_uint256does.nBits = 0xff123456produces a target of ~2040 bits, which Core rejects viafOverflow:The negative-flag and zero-target cases happen to return
falseanyway through the arithmetic, so they are not exploitable — but they are also not checked.Impact is worst in the SPV path
btc:rule-spv-powbinds directly tocheckProofOfWork, and the SPV ruleset has no difficulty rule to compensate. InvalidateChain,btc:rule-header-difficultymasks the problem whenprevandepochFirstcontext is available, and returnsnullwhen it is not.So a forged inclusion proof costs nothing. Fabricating a "block" containing a transaction that was never mined, with
nonce: 1and no work performed:Reproduction:
bits: 0x207fffffis the regtest-style target, evaluated by a mainnet-configured kernel.SPV correctly requires the caller to anchor the header to a known chain, and a caller that does so is protected. But
SpvEngine.verify()returnsok: truewith no indication that an external check is required, and a rule namedbtc:rule-spv-powthat provides no work guarantee is worse than no rule — it reads as assurance.Suggested fix
Give
Codecthe network'spowLimit(or pass a target bound intocheckProofOfWork), and reject when the expanded target is zero, exceeds 256 bits, or exceedspowLimit. Surfacing the negative flag would complete the parity with Core.Affects every vendored copy —
kernel,browser-node,health,forks,mempool— sincecodec.jsis byte-identical across all five.