Skip to content

Commit 6acecdb

Browse files
committed
Refuse unsafe MWEB CompactSize values and trailing transaction bytes with a typed parse error
transactionFromHex now consumes exactly the bytes it is given and wraps every wire-parse failure, in it and in the block path, in TransactionParseError, and the MWEB CompactSize reader refuses values above Number.MAX_SAFE_INTEGER. Fixed node-txid vectors, truncation and trailing-byte cases, and a mempool containment test cover the change, and the parse fuzz harness accepts the typed error by its code.
1 parent dda336d commit 6acecdb

5 files changed

Lines changed: 269 additions & 11 deletions

File tree

‎src/chain/XChainBlockDecoder.js‎

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ const coins = require('../coins');
1919
const LITECOIN_HOGEX_FLAG = 0x08
2020
const LITECOIN_MWEB_SEGWIT_FLAG = 0x09
2121

22+
class TransactionParseError extends Error {
23+
constructor(message, cause) {
24+
super(message, cause ? { cause } : undefined)
25+
this.name = 'TransactionParseError'
26+
this.code = 'ERR_TRANSACTION_PARSE'
27+
}
28+
}
29+
30+
function transactionParseError(message, cause) {
31+
if (cause instanceof TransactionParseError) return cause
32+
return new TransactionParseError(message, cause)
33+
}
34+
2235
// Block/tx wire-serialization families this decoder actually implements. The
2336
// per-coin branches below (Litecoin 'mweb' MWEB-flagged tx reading, Dogecoin
2437
// 'auxpow' handled upstream in BlockchainConnector.getBlockWithoutAuxPow,
@@ -56,7 +69,11 @@ function readMwebCompactSize(buf, offset, what) {
5669
const end = skipMwebBytes(buf, offset + 1, width, what)
5770
if (width === 2) return { value: buf.readUInt16LE(offset + 1), end }
5871
if (width === 4) return { value: buf.readUInt32LE(offset + 1), end }
59-
return { value: Number(buf.readBigUInt64LE(offset + 1)), end }
72+
const value = buf.readBigUInt64LE(offset + 1)
73+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
74+
throw new TransactionParseError('MWEB tx parse: ' + what + ' exceeds Number.MAX_SAFE_INTEGER')
75+
}
76+
return { value: Number(value), end }
6077
}
6178

6279
function skipMwebVarBytes(buf, offset, what) {
@@ -209,6 +226,10 @@ function readMwebTransactions(bufferReader, block) {
209226

210227
class XChainBlockDecoder {
211228

229+
// Callers tell a wire-parse failure apart by this class, reached through the one
230+
// export, so the module keeps a single export shape.
231+
static TransactionParseError = TransactionParseError
232+
212233
constructor(networkName) {
213234
let networkNameSplit = networkName.split("-")
214235
this.coin = networkNameSplit[0]
@@ -227,14 +248,28 @@ class XChainBlockDecoder {
227248
}
228249

229250
transactionFromHex(txHex){
230-
const buffer = Buffer.from(txHex, "hex")
231-
if (this.wireFormat === "mweb" && hasMwebFlag(buffer, 0)) {
232-
// Litecoin's getrawtransaction answers in the MWEB serialization when the
233-
// tx has an MWEB part; bitcoinjs cannot read that flag, so read it here.
234-
return readMwebFlaggedTransaction(buffer, 0, true).tx
251+
try {
252+
if (typeof txHex !== 'string' || txHex.length === 0 || (txHex.length % 2) !== 0 || !/^[0-9a-fA-F]+$/.test(txHex)) {
253+
throw new TransactionParseError('Transaction hex must be a non-empty, even-length hexadecimal string')
254+
}
255+
const buffer = Buffer.from(txHex, "hex")
256+
if (this.wireFormat === "mweb" && hasMwebFlag(buffer, 0)) {
257+
// Litecoin's getrawtransaction answers in the MWEB serialization when the
258+
// tx has an MWEB part; bitcoinjs cannot read that flag, so read it here.
259+
const parsed = readMwebFlaggedTransaction(buffer, 0, true)
260+
if (parsed.consumed !== buffer.length) {
261+
throw new TransactionParseError('Transaction has ' + (buffer.length - parsed.consumed) + ' trailing byte(s)')
262+
}
263+
return parsed.tx
264+
}
265+
const tx = transaction_js_1.Transaction.fromBuffer(buffer, true)
266+
if (tx.byteLength() !== buffer.length) {
267+
throw new TransactionParseError('Transaction has ' + (buffer.length - tx.byteLength()) + ' trailing byte(s)')
268+
}
269+
return tx
270+
} catch (err) {
271+
throw transactionParseError('Transaction parse failed: ' + (err && err.message ? err.message : err), err)
235272
}
236-
// Non-strict mode ignores any bytes after the tx.
237-
return transaction_js_1.Transaction.fromBuffer(buffer, true)
238273
}
239274

240275
doubleSha256AndReverse(data){
@@ -261,14 +296,24 @@ class XChainBlockDecoder {
261296
block.bits = bufferReader.readUInt32();
262297
block.nonce = bufferReader.readUInt32();
263298
if (buffer.length === 80) return block;
264-
const extensionFollows = readMwebTransactions(bufferReader, block)
299+
let extensionFollows
300+
try {
301+
extensionFollows = readMwebTransactions(bufferReader, block)
302+
} catch (err) {
303+
throw transactionParseError('Block transaction parse failed: ' + (err && err.message ? err.message : err), err)
304+
}
265305
assertBlockBodyIntact(block, buffer.length, bufferReader.offset, extensionFollows)
266306
const witnessCommit = block.getWitnessCommit();
267307
// This Block contains a witness commit
268308
if (witnessCommit) block.witnessCommit = witnessCommit;
269309
return block;
270310
default: {
271-
const plainBlock = bitcoinjs.Block.fromBuffer(buffer)
311+
let plainBlock
312+
try {
313+
plainBlock = bitcoinjs.Block.fromBuffer(buffer)
314+
} catch (err) {
315+
throw transactionParseError('Block transaction parse failed: ' + (err && err.message ? err.message : err), err)
316+
}
272317
// An 80-byte buffer is a header-only parse, with no body to check.
273318
if (buffer.length === 80) return plainBlock
274319
assertBlockBodyIntact(plainBlock, buffer.length, plainBlock.byteLength(), false)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"bitcoinGenesisCoinbase": {
3+
"form": "legacy",
4+
"txid": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
5+
"hex": "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000"
6+
},
7+
"taprootWitnessSpend": {
8+
"form": "segwit-v1",
9+
"txid": "065e8cd3378b9925feee408ae9e5f58e5bba75e8d7682a7fbcf230a3b435f7bb",
10+
"hex": "0200000000010111111111111111111111111111111111111111111111111111111111111111110300000000fdffffff013930000000000000225120222222222222222222222222222222222222222222222222222222222222222201403333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333340d10c00"
11+
}
12+
}

‎test/fuzz/harness/parse_transaction.fuzz/support.cjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,12 @@ function createDecoder() {
4444
}
4545

4646
// Errors thrown by bitcoinjs-lib during Transaction.fromHex/fromBuffer are expected
47-
// when we feed it corrupted hex. These are not decoder bugs.
47+
// when we feed it corrupted hex. These are not decoder bugs. transactionFromHex
48+
// refuses malformed, truncated and trailing-byte hex with a TransactionParseError,
49+
// keyed here on its code rather than its message, so a refusal stops counting as
50+
// expected the moment the decoder stops typing it.
4851
function isBitcoinjsParseError(err) {
52+
if (err != null && err.code === 'ERR_TRANSACTION_PARSE') return true
4953
const msg = err.message || ''
5054
return msg.includes('Cannot read slice out of bounds') ||
5155
msg.includes('Transaction has unexpected data') ||

‎test/unit/wire_parse/mempool_node_txid.test.js‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,14 @@ describe('decoder mempool rows keyed on the node txid @regression', function ()
7171
assert.deepStrictEqual(seen.parsed, [])
7272
assert.strictEqual(decoder.parseErrors, before + 1)
7373
})
74+
75+
it('handles a typed malformed-transaction error without aborting the mempool cycle', async function () {
76+
const { decoder, seen } = buildDecoder('cd'.repeat(32), 'not-hex')
77+
const before = decoder.parseErrors
78+
await decoder.updateMempool()
79+
assert.deepStrictEqual(seen.rows, [])
80+
assert.deepStrictEqual(seen.parsed, [])
81+
assert.strictEqual(decoder.parseErrors, before + 1)
82+
})
7483
})
7584
})
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright © 2025–2026 Dankest, LLC
2+
// Based on XChain Platform by Dankest, LLC – https://dankest.llc
3+
//
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
'use strict'
7+
8+
const assert = require('assert')
9+
const bitcoin = require('bitcoinjs-lib')
10+
const ecc = require('tiny-secp256k1')
11+
const XChainDecoder = require('../../../src/XChainDecoder')
12+
const XChainBlockDecoder = require('../../../src/chain/XChainBlockDecoder')
13+
const CryptoNetworks = require('../../../src/chain/crypto_networks')
14+
const NEW_VECTORS = require('../../fixtures/decoder_parse_vectors.json')
15+
const WIRE_VECTORS = require('../../fixtures/wire_parse_vectors.json')
16+
17+
bitcoin.initEccLib(ecc)
18+
19+
const P2PKH = Buffer.from('76a914' + '11'.repeat(20) + '88ac', 'hex')
20+
const P2SH = Buffer.from('a914' + '22'.repeat(20) + '87', 'hex')
21+
const P2WPKH = Buffer.from('0014' + '33'.repeat(20), 'hex')
22+
const P2WSH = Buffer.from('0020' + '44'.repeat(32), 'hex')
23+
const P2TR = Buffer.from('5120' + '22'.repeat(32), 'hex')
24+
25+
describe('transaction wire parsing conformance', function () {
26+
it('matches node txids for legacy, segwit v0, taproot v1, LTC MWEB and DOGE coinbase forms', function () {
27+
const forms = [
28+
['bitcoin-mainnet', NEW_VECTORS.bitcoinGenesisCoinbase],
29+
['bitcoin-mainnet', WIRE_VECTORS.ltcPegin],
30+
['bitcoin-mainnet', NEW_VECTORS.taprootWitnessSpend],
31+
['litecoin-mainnet', WIRE_VECTORS.ltcHogEx3195200],
32+
['dogecoin-mainnet', { hex: WIRE_VECTORS.doge4500000.txs[0], txid: WIRE_VECTORS.doge4500000.txids[0] }],
33+
]
34+
35+
for (const [network, vector] of forms) {
36+
assert.strictEqual(new XChainBlockDecoder(network).transactionFromHex(vector.hex).getId(), vector.txid)
37+
}
38+
})
39+
40+
it('rejects truncated, non-hex and trailing bytes with a typed error', function () {
41+
const decoder = new XChainBlockDecoder('bitcoin-mainnet')
42+
const valid = NEW_VECTORS.taprootWitnessSpend.hex
43+
const malformed = [
44+
'',
45+
'0',
46+
'zz',
47+
valid.slice(0, -2),
48+
valid + '00',
49+
]
50+
51+
for (const hex of malformed) {
52+
assert.throws(
53+
() => decoder.transactionFromHex(hex),
54+
err => err instanceof XChainBlockDecoder.TransactionParseError && err.code === 'ERR_TRANSACTION_PARSE'
55+
)
56+
}
57+
})
58+
59+
it('rejects an MWEB CompactSize above 2^53 before using it as a length', function () {
60+
// version 2, MWEB flag 0x08, then input-count CompactSize 2^53.
61+
const unsafeInputCount = '020000000008ff0000000000002000'
62+
assert.throws(
63+
() => new XChainBlockDecoder('litecoin-mainnet').transactionFromHex(unsafeInputCount),
64+
err => err instanceof XChainBlockDecoder.TransactionParseError &&
65+
/exceeds Number\.MAX_SAFE_INTEGER/.test(err.message)
66+
)
67+
})
68+
})
69+
70+
describe('transaction wire parsing conformance', function () {
71+
it('bounds-checks every truncation of a fixed MWEB transaction', function () {
72+
const decoder = new XChainBlockDecoder('litecoin-mainnet')
73+
const valid = WIRE_VECTORS.ltcHogEx3195200.hex
74+
for (let end = 0; end < valid.length; end += 2) {
75+
assert.throws(
76+
() => decoder.transactionFromHex(valid.slice(0, end)),
77+
err => err instanceof XChainBlockDecoder.TransactionParseError
78+
)
79+
}
80+
assert.throws(
81+
() => decoder.transactionFromHex(valid + '00'),
82+
err => err instanceof XChainBlockDecoder.TransactionParseError && /trailing byte/.test(err.message)
83+
)
84+
})
85+
86+
it('types a truncated transaction inside a block for the block-loop caller', function () {
87+
const block = Buffer.concat([
88+
Buffer.from(WIRE_VECTORS.doge4500000.header, 'hex'),
89+
Buffer.from([WIRE_VECTORS.doge4500000.txs.length]),
90+
...WIRE_VECTORS.doge4500000.txs.map(hex => Buffer.from(hex, 'hex')),
91+
])
92+
assert.throws(
93+
() => new XChainBlockDecoder('dogecoin-mainnet').blockFromBuffer(block.subarray(0, -1)),
94+
err => err instanceof XChainBlockDecoder.TransactionParseError && err.code === 'ERR_TRANSACTION_PARSE'
95+
)
96+
})
97+
})
98+
99+
// The addresses each network derives for P2PKH, P2SH, P2WPKH, P2WSH and P2TR, in
100+
// that order; DOGE has no witness outputs, so it lists only the first two.
101+
const EXPECTED_ADDRESSES = {
102+
'bitcoin-mainnet': [
103+
'12ZEw5Hcv1hTb6YUQJ69y1V7uhcoDz92PH',
104+
'34oVnh4gNviJGMnNvgquMeLAxvXJuaRVMZ',
105+
'bc1qxvenxvenxvenxvenxvenxvenxvenxven2ymjt8',
106+
'bc1qg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zqd8sxw4',
107+
'bc1pyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3qfpjpqe',
108+
],
109+
'bitcoin-testnet': [
110+
'mh5CE8Nbj38iND267s4XnvhSmhDW7yWc6Q',
111+
'2MvMhrRzhzPDeU9QvbpTmybKSBGjUmC6TTu',
112+
'tb1qxvenxvenxvenxvenxvenxvenxvenxvenqzqps5',
113+
'tb1qg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zq60xf56',
114+
'tb1pyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3q7fyw6k',
115+
],
116+
'bitcoin-regtest': [
117+
'mh5CE8Nbj38iND267s4XnvhSmhDW7yWc6Q',
118+
'2MvMhrRzhzPDeU9QvbpTmybKSBGjUmC6TTu',
119+
'bcrt1qxvenxvenxvenxvenxvenxvenxvenxvenztev8a',
120+
'bcrt1qg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zqhkv0pq',
121+
'bcrt1pyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3qnswg0v',
122+
],
123+
'litecoin-mainnet': [
124+
'LLnCCHbSzfwWquEdaS5TF2Yt7uz5Qb1SZ1',
125+
'MB1e6aUeL3Zj4s4H2ZqFBHaaHd7kvvzTco',
126+
'ltc1qxvenxvenxvenxvenxvenxvenxvenxvenwcpknh',
127+
'ltc1qg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zqwr7k5s',
128+
'ltc1pyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3q29u36u',
129+
],
130+
'litecoin-testnet': [
131+
'mh5CE8Nbj38iND267s4XnvhSmhDW7yWc6Q',
132+
'2MvMhrRzhzPDeU9QvbpTmybKSBGjUmC6TTu',
133+
'tltc1qxvenxvenxvenxvenxvenxvenxvenxvene2zlqa',
134+
'tltc1qg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zq9v6gt9',
135+
'tltc1pyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3qp2c09f',
136+
],
137+
'litecoin-regtest': [
138+
'mh5CE8Nbj38iND267s4XnvhSmhDW7yWc6Q',
139+
'2MvMhrRzhzPDeU9QvbpTmybKSBGjUmC6TTu',
140+
'rltc1qxvenxvenxvenxvenxvenxvenxvenxvenuxr9sr',
141+
'rltc1qg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zql4xswd',
142+
'rltc1pyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3qmnyhqp',
143+
],
144+
'dogecoin-mainnet': [
145+
'D6hLULEGDRbk86j58t5iWmeinqM6acA16V',
146+
'9uYkXY8aSzbCAj9rLpWKbmxYfVuM33MqfZ',
147+
],
148+
'dogecoin-testnet': [
149+
'nVkQCLyB9Q4U15JGAhjAmBF22hjPYUwj3w',
150+
'2MvMhrRzhzPDeU9QvbpTmybKSBGjUmC6TTu',
151+
],
152+
'dogecoin-regtest': [
153+
'mh5CE8Nbj38iND267s4XnvhSmhDW7yWc6Q',
154+
'2MvMhrRzhzPDeU9QvbpTmybKSBGjUmC6TTu',
155+
],
156+
}
157+
158+
async function assertNetworkAddresses(network, addresses) {
159+
const decoder = new XChainDecoder(
160+
network, null, null, null, null, null,
161+
'127.0.0.1', 0, null, null, false
162+
)
163+
const tx = new bitcoin.Transaction()
164+
tx.addInput(Buffer.alloc(32, 1), 0)
165+
const scripts = network.startsWith('dogecoin')
166+
? [P2PKH, P2SH]
167+
: [P2PKH, P2SH, P2WPKH, P2WSH, P2TR]
168+
for (const script of scripts) tx.addOutput(script, 1)
169+
170+
const parsed = await decoder.parseTransaction(tx, new Set())
171+
assert.deepStrictEqual(parsed.paymentOutputs.map(output => output.destinationAddress), addresses)
172+
173+
if (!network.startsWith('dogecoin')) {
174+
assert.strictEqual(bitcoin.address.fromOutputScript(P2WPKH, CryptoNetworks.getBitcoinJsNetwork(network)), addresses[2])
175+
assert.strictEqual(bitcoin.address.fromOutputScript(P2TR, CryptoNetworks.getBitcoinJsNetwork(network)), addresses[4])
176+
} else {
177+
assert.throws(() => bitcoin.address.fromOutputScript(P2WPKH, CryptoNetworks.getBitcoinJsNetwork(network)))
178+
assert.throws(() => bitcoin.address.fromOutputScript(P2TR, CryptoNetworks.getBitcoinJsNetwork(network)))
179+
}
180+
}
181+
182+
describe('output address derivation conformance', function () {
183+
for (const [network, addresses] of Object.entries(EXPECTED_ADDRESSES)) {
184+
it(network + ' derives the coin prefixes and witness checksum variant', async function () {
185+
await assertNetworkAddresses(network, addresses)
186+
})
187+
}
188+
})

0 commit comments

Comments
 (0)