Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ In the [example](https://github.com/mrtnetwork/bitcoin_base/tree/main/example/li
const String memo = "https://github.com/mrtnetwork";

/// SUM OF OUTOUT AMOUNTS
final sumOfOutputs = outPuts.fold(
final sumOfOutputs = outputs.fold(
BigInt.zero, (previousValue, element) => previousValue + element.value);

/// Estimate transaction size
Expand Down
43 changes: 41 additions & 2 deletions lib/src/bitcoin/script/transaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,25 @@ class BtcTransaction {
}

/// Instantiates a Transaction from serialized raw hexadacimal data (classmethod)
///
/// Throws a [BitcoinBasePluginException] (rather than a bare, uninformative
/// [RangeError]/[FormatException]) when [raw] is malformed or truncated -
/// e.g. a raw tx hex cut short by an upstream network/transport bug before
/// every field could be read.
static BtcTransaction fromRaw(String raw) {
try {
return _fromRawUnchecked(raw);
} on BitcoinBasePluginException {
rethrow;
} catch (e) {
throw BitcoinBasePluginException(
'Malformed or truncated raw transaction hex: $e',
details: {'hexLength': raw.length},
);
}
}

static BtcTransaction _fromRawUnchecked(String raw) {
final rawtx = BytesUtils.fromHexString(raw);
final List<int> version = rawtx.sublist(0, 4);
int cursor = 4;
Expand Down Expand Up @@ -124,8 +142,21 @@ class BtcTransaction {
List<TxWitnessInput> witnesses = [];
if (hasSegwit) {
for (int n = 0; n < inputs.length; n++) {
final input = inputs[n];
if (input.scriptSig.script.isNotEmpty) continue;
// Per BIP144, every input gets exactly one witness field when the
// segwit flag is set - including legacy inputs (an empty stack,
// serialized as a single 0x00 byte) and P2SH-wrapped segwit inputs
// (non-empty scriptSig *and* non-empty witness). Skipping the read
// here for any input with a non-empty scriptSig desyncs the cursor
// for every witness read after it.
//
// The writer side (toBytes()'s `if (segwit)` below, reached via
// toHex()/serialize()/getSize() passing `segwit: hasSegwit`) already
// writes one witness entry per input unconditionally, so this loop
// must mirror that to round-trip correctly.
//
// final input = inputs[n];
// if (input.scriptSig.script.isNotEmpty) continue;
// /\ keep this removed

final wVi = IntUtils.decodeVarint(rawtx.sublist(cursor, cursor + 9));
cursor += wVi.item2;
Expand All @@ -146,6 +177,14 @@ class BtcTransaction {
List<int>? mwebBytes;
if (hasMweb) {
mwebBytes = rawtx.sublist(cursor, rawtx.length - 4);
} else if (cursor != rawtx.length - 4) {
// `cursor` should land exactly 4 bytes before the end once inputs,
// outputs, and any witnesses are consumed.
throw BitcoinBasePluginException(
'Malformed or truncated raw transaction hex: expected locktime at '
'offset ${rawtx.length - 4}, but parsing ended at offset $cursor',
details: {'hexLength': raw.length},
);
}
cursor = rawtx.length - 4;
List<int> lock = rawtx.sublist(cursor, cursor + 4);
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/large_tx_260in_340out.hex

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions test/fixtures/large_tx_365in_454out.hex

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions test/large_transaction_parsing_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import 'dart:io';

import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:test/test.dart';

String _readFixtureHex(String name) =>
File('test/fixtures/$name').readAsStringSync().trim();

String _toHex(List<int> bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();

void main() {
group('BtcTransaction.fromRaw - large transactions', () {
// These are two real, on-chain consolidation-style transactions with
// well over 252 inputs/outputs each - the threshold at which Bitcoin's
// CompactSize varint encoding switches from a 1-byte count to a 3-byte
// (0xfd + 2 bytes LE) prefix. A wallet fetching either of these must be
// able to parse them like any other transaction.
test('parses a real transaction with 260 inputs / 340 outputs', () {
final hex = _readFixtureHex('large_tx_260in_340out.hex');
final tx = BtcTransaction.fromRaw(hex);

expect(tx.inputs.length, 260);
expect(tx.outputs.length, 340);
expect(tx.hasSegwit, isTrue);
});

test('parses a real transaction with 365 inputs / 454 outputs', () {
final hex = _readFixtureHex('large_tx_365in_454out.hex');
final tx = BtcTransaction.fromRaw(hex);

expect(tx.inputs.length, 365);
expect(tx.outputs.length, 454);
expect(tx.hasSegwit, isTrue);
});

test('a re-serialized large transaction round-trips to the same txid', () {
final hex = _readFixtureHex('large_tx_260in_340out.hex');
final tx = BtcTransaction.fromRaw(hex);
final reencoded =
BtcTransaction.fromRaw(_toHex(tx.toBytes(segwit: true)));

expect(reencoded.inputs.length, tx.inputs.length);
expect(reencoded.outputs.length, tx.outputs.length);
expect(reencoded.hasSegwit, tx.hasSegwit);

expect(reencoded.txId(), tx.txId());
});

group('truncated/malformed input', () {
test(
'the exception message identifies it as a parsing failure and '
'carries the input length for diagnostics', () {
final truncatedHex =
_readFixtureHex('large_tx_260in_340out.hex').substring(0, 100);

try {
BtcTransaction.fromRaw(truncatedHex);
fail('expected BtcTransaction.fromRaw to throw');
} on BitcoinBasePluginException catch (e) {
expect(e.message.toLowerCase(), contains('malformed'));
expect(e.details?['hexLength'], truncatedHex.length);
}
});

test(
'truncating only the trailing locktime bytes now throws instead '
'of silently absorbing a wrong locktime', () {
// For non-mweb transactions, `cursor` after inputs/outputs/witnesses
// is validated against the expected `rawtx.length - 4` locktime
// offset, so cutting off the last 4 bytes (or any amount) is caught
// here instead of being silently reinterpreted as a different,
// wrong-but-still-4-byte locktime.
final fullHex = _readFixtureHex('large_tx_365in_454out.hex');
final truncatedHex = fullHex.substring(0, fullHex.length - 4);

expect(
() => BtcTransaction.fromRaw(truncatedHex),
throwsA(isA<BitcoinBasePluginException>()),
);
});

test('throws a typed exception for a hex only a few bytes long', () {
expect(
() => BtcTransaction.fromRaw('01000000'),
throwsA(isA<BitcoinBasePluginException>()),
);
});

test('throws a typed exception for an empty string', () {
expect(
() => BtcTransaction.fromRaw(''),
throwsA(isA<BitcoinBasePluginException>()),
);
});
});
});
}
114 changes: 114 additions & 0 deletions test/segwit_witness_desync_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:test/test.dart';

String _toHex(List<int> bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();

/// A hex string of exactly [byteLength] bytes, built from [nibble] - avoids
/// hand-typed repeated-character literals silently being the wrong length.
String _hexOf(int byteLength, String nibble) => nibble * (byteLength * 2);

void main() {
group('BtcTransaction.fromRaw - witness/scriptSig desync regression', () {
// Per BIP144, once the segwit marker+flag is set, EVERY input gets
// exactly one witness field - including a legacy/P2SH-wrapped input
// whose actual unlock data lives in a non-empty scriptSig rather than
// (only) in the witness. `fromRaw` used to `continue` (skip reading a
// witness entry entirely) for any input with a non-empty scriptSig,
// which desyncs the cursor for every witness read after it - dropping
// or corrupting the witness data of every subsequent input.
test(
'reads a P2SH-wrapped-segwit input\'s witness and does not '
'desync a later plain-segwit input\'s witness', () {
// Input 0: P2SH-wrapped segwit style - non-empty scriptSig (the
// "redeem script push") *and* a real witness stack (sig + pubkey).
final wrappedInput = TxInput(
txId: _hexOf(32, 'a'),
txIndex: 0,
scriptSig: Script(script: [_hexOf(20, 'e')]),
);
final wrappedWitness = TxWitnessInput(stack: [
_hexOf(71, '1'),
_hexOf(33, '2'),
]);

// Input 1: plain segwit - empty scriptSig, its own distinct witness.
// If input 0's witness were skipped, this witness would be read at
// the wrong cursor position (reading input 0's actual witness bytes
// instead, or running past the buffer).
final plainInput = TxInput(txId: _hexOf(32, 'b'), txIndex: 1);
final plainWitness = TxWitnessInput(stack: [
_hexOf(72, '3'),
_hexOf(33, '4'),
]);

final output = TxOutput(
amount: BigInt.from(50000),
scriptPubKey: Script(script: [
'OP_DUP',
'OP_HASH160',
_hexOf(20, 'f'),
'OP_EQUALVERIFY',
'OP_CHECKSIG',
]),
);

final original = BtcTransaction(
inputs: [wrappedInput, plainInput],
outputs: [output],
witnesses: [wrappedWitness, plainWitness],
hasSegwit: true,
);

final hex = _toHex(original.toBytes(segwit: true));
final parsed = BtcTransaction.fromRaw(hex);

expect(parsed.inputs.length, 2);
expect(parsed.witnesses.length, 2,
reason: 'every input must get its own witness entry, including '
'the one with a non-empty scriptSig');

expect(parsed.inputs[0].scriptSig.script, wrappedInput.scriptSig.script);
expect(parsed.witnesses[0].stack, wrappedWitness.stack);

expect(parsed.inputs[1].scriptSig.script, isEmpty);
expect(parsed.witnesses[1].stack, plainWitness.stack);
});

test(
'reads an empty witness stack for a legacy input mixed into a '
'segwit transaction (the 0x00 "no witness items" placeholder)', () {
final legacyInput = TxInput(
txId: _hexOf(32, 'c'),
txIndex: 0,
scriptSig: Script(script: [_hexOf(71, '5'), _hexOf(33, '6')]),
);

final segwitInput = TxInput(txId: _hexOf(32, 'd'), txIndex: 0);
final segwitWitness = TxWitnessInput(stack: [
_hexOf(71, '7'),
_hexOf(33, '8'),
]);

final output = TxOutput(
amount: BigInt.from(1000),
scriptPubKey: Script(script: ['OP_TRUE']),
);

final original = BtcTransaction(
inputs: [legacyInput, segwitInput],
outputs: [output],
// The legacy input still gets an entry - an empty stack.
witnesses: [TxWitnessInput(stack: const []), segwitWitness],
hasSegwit: true,
);

final hex = _toHex(original.toBytes(segwit: true));
final parsed = BtcTransaction.fromRaw(hex);

expect(parsed.witnesses.length, 2);
expect(parsed.witnesses[0].stack, isEmpty);
expect(parsed.witnesses[1].stack, segwitWitness.stack);
});
});
}
6 changes: 3 additions & 3 deletions test/transaction_builder_locktime_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ void main() {
group('BitcoinTransactionBuilder locktime', () {
test('defaults to defaultTxLocktime', () {
final b = BitcoinTransactionBuilder(
outPuts: const [],
outputs: const [],
fee: BigInt.zero,
network: BitcoinNetwork.mainnet,
utxos: const [],
);
expect(b.locktime, BitcoinOpCodeConst.defaultTxLocktime);
expect(b.locktime, BitcoinOpCodeConst.DEFAULT_TX_LOCKTIME);
});

test('stores the provided locktime', () {
final lt = [0x50, 0x01, 0xcf, 0x00];
final b = BitcoinTransactionBuilder(
outPuts: const [],
outputs: const [],
fee: BigInt.zero,
network: BitcoinNetwork.mainnet,
utxos: const [],
Expand Down