Skip to content

Commit

Permalink
Fix psbt parsing and tap_bip32_derivation. Closes gh-100, gh-101
Browse files Browse the repository at this point in the history
  • Loading branch information
paulmillr committed Jul 18, 2024
1 parent 4cb0c3e commit 9df4112
Show file tree
Hide file tree
Showing 7 changed files with 156 additions and 13 deletions.
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export {
} from './payment.js';
// prettier-ignore
export {
OP, RawTx, CompactSize,
OP, RawTx, CompactSize, RawWitness,
Script, ScriptNum, ScriptType, MAX_SCRIPT_BYTE_LENGTH,
} from './script.js';
export { Transaction } from './transaction.js';
Expand Down
7 changes: 3 additions & 4 deletions src/psbt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { hex } from '@scure/base';
import * as P from 'micro-packed';
import { CompactSize, CompactSizeLen, RawOutput, RawTx, RawWitness, VarBytes } from './script.js';
import { CompactSize, CompactSizeLen, VarBytes } from './script.js';
import { RawOutput, RawTx, RawOldTx, RawWitness } from './script.js';
import { Transaction } from './transaction.js'; // circular
import { Bytes, compareBytes, PubT, validatePubkey, equalBytes } from './utils.js';

Expand Down Expand Up @@ -60,7 +61,7 @@ const Bytes32 = P.bytes(32);
// Tables from BIP-0174 (https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)
// prettier-ignore
export const PSBTGlobal = {
unsignedTx: [0x00, false, RawTx, [0], [0], false],
unsignedTx: [0x00, false, RawOldTx, [0], [0], false],
xpub: [0x01, GlobalXPUB, BIP32Der, [], [0, 2], false],
txVersion: [0x02, false, P.U32LE, [2], [2], false],
fallbackLocktime: [0x03, false, P.U32LE, [], [2], false],
Expand Down Expand Up @@ -342,8 +343,6 @@ const PSBTGlobalCoder = P.validate(PSBTKeyMap(PSBTGlobal), (g) => {
const version = g.version || 0;
if (version === 0) {
if (!g.unsignedTx) throw new Error('PSBTv0: missing unsignedTx');
if (g.unsignedTx.segwitFlag || g.unsignedTx.witnesses)
throw new Error('PSBTv0: witness in unsingedTx');
for (const inp of g.unsignedTx.inputs)
if (inp.finalScriptSig && inp.finalScriptSig.length)
throw new Error('PSBTv0: input scriptSig found in unsignedTx');
Expand Down
7 changes: 7 additions & 0 deletions src/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,10 @@ function validateRawTx(tx: P.UnwrapCoder<typeof _RawTx>) {
return tx;
}
export const RawTx = P.validate(_RawTx, validateRawTx);
// Pre-SegWit serialization format (for PSBTv0)
export const RawOldTx = P.struct({
version: P.I32LE,
inputs: BTCArray(RawInput),
outputs: BTCArray(RawOutput),
lockTime: P.U32LE,
});
34 changes: 28 additions & 6 deletions src/transaction.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as P from 'micro-packed';
import { hex } from '@scure/base';

import { Address, CustomScript, OutScript, checkScript, tapLeafHash } from './payment.js';
import * as psbt from './psbt.js'; // circular
import { CompactSizeLen, RawOutput, RawTx, RawWitness, Script, VarBytes } from './script.js';
import { CompactSizeLen, Script, VarBytes } from './script.js';
import { RawOutput, RawTx, RawOldTx, RawWitness } from './script.js';
import { NETWORK, Bytes, concatBytes, isBytes, equalBytes } from './utils.js';
import * as u from './utils.js';
import { getInputType, toVsize, normalizeInput, getPrevOut } from './utxo.js'; // circular
Expand Down Expand Up @@ -284,6 +284,11 @@ export class Transaction {
toPSBT(PSBTVersion = this.opts.PSBTVersion) {
if (PSBTVersion !== 0 && PSBTVersion !== 2)
throw new Error(`Wrong PSBT version=${PSBTVersion}`);
// if (PSBTVersion === 0 && this.inputs.length === 0) {
// throw new Error(
// 'PSBT version=0 export for transaction without inputs disabled, please use version=2. Please check `toPSBT` method for explanation.'
// );
// }
const inputs = this.inputs.map((i) => psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTInput, i));
for (const inp of inputs) {
// Don't serialize empty fields
Expand All @@ -294,7 +299,23 @@ export class Transaction {
const outputs = this.outputs.map((i) => psbt.cleanPSBTFields(PSBTVersion, psbt.PSBTOutput, i));
const global = { ...this.global };
if (PSBTVersion === 0) {
global.unsignedTx = RawTx.decode(this.unsignedTx);
/*
- Bitcoin raw transaction expects to have at least 1 input because it uses case with zero inputs as marker for SegWit
- this means we cannot serialize raw tx with zero inputs since it will be parsed as SegWit tx
- Parsing of PSBTv0 depends on unsignedTx (it looks for input count here)
- BIP-174 requires old serialization format (without witnesses) inside global, which solves this
*/
global.unsignedTx = RawOldTx.decode(
RawOldTx.encode({
version: this.version,
lockTime: this.lockTime,
inputs: this.inputs.map(inputBeforeSign).map((i) => ({
...i,
finalScriptSig: P.EMPTY,
})),
outputs: this.outputs.map(outputBeforeSign),
})
);
delete global.fallbackLocktime;
delete global.txVersion;
} else {
Expand Down Expand Up @@ -744,7 +765,6 @@ export class Transaction {
// Taproot
const prevOut = getPrevOut(input);
if (inputType.txType === 'taproot') {
if (input.tapBip32Derivation) throw new Error('tapBip32Derivation unsupported');
const prevOuts = this.inputs.map(getPrevOut);
const prevOutScript = prevOuts.map((i) => i.script);
const amount = prevOuts.map((i) => i.amount);
Expand Down Expand Up @@ -1030,8 +1050,10 @@ export class Transaction {
);
}
}
const thisUnsigned = this.global.unsignedTx ? RawTx.encode(this.global.unsignedTx) : P.EMPTY;
const otherUnsigned = other.global.unsignedTx ? RawTx.encode(other.global.unsignedTx) : P.EMPTY;
const thisUnsigned = this.global.unsignedTx ? RawOldTx.encode(this.global.unsignedTx) : P.EMPTY;
const otherUnsigned = other.global.unsignedTx
? RawOldTx.encode(other.global.unsignedTx)
: P.EMPTY;
if (!equalBytes(thisUnsigned, otherUnsigned))
throw new Error(`Transaction/combine: different unsigned tx`);
this.global = psbt.mergeKeyMap(psbt.PSBTGlobal, this.global, other.global);
Expand Down
115 changes: 115 additions & 0 deletions test/basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1871,6 +1871,121 @@ should('getOutputAddress', () => {
}
});

should('GH-100: end-of-buffer psbt', () => {
const psbt = new btc.Transaction();
psbt.addOutput({
amount: BigInt(10000),
script: hex.decode('51203701d8f81bf26a07eebef592f2960e4b6db32b09fce20246db0842ebfc45001b'),
});

const decoded2 = btc.Transaction.fromPSBT(psbt.toPSBT(2));
deepStrictEqual(decoded2.inputs, psbt.inputs);
deepStrictEqual(decoded2.outputs, psbt.outputs);

const decoded0 = btc.Transaction.fromPSBT(psbt.toPSBT(0));
deepStrictEqual(decoded0.inputs, psbt.inputs);
deepStrictEqual(decoded0.outputs, psbt.outputs);
// ./bin/bitcoin-cli createpsbt [] '[{"bc1pxuqa37qm7f4q0m477kf099swfdkmx2cfln3qy3kmpppwhlz9qqdsre4trt":0.00010000}]'
const corePsbt = base64.decode(
'cHNidP8BADUCAAAAAAEQJwAAAAAAACJRIDcB2Pgb8moH7r71kvKWDkttsysJ/OICRtsIQuv8RQAbAAAAAAAA'
);
deepStrictEqual(psbt.toPSBT(0), corePsbt);
// ./bin/bitcoin-cli createpsbt [] []
const coreEmpty = base64.decode('cHNidP8BAAoCAAAAAAAAAAAAAA==');
deepStrictEqual(new btc.Transaction().toPSBT(0), coreEmpty);
// NOTE: bitcoinjs does very strange things (including silently fallback into PSBTv2) and generates non-valid PSBT which
// cannot be parsed by bitcoin-cli
});

should('GH-101: TAP_BIP32_DERIVATION', () => {
const opts = {};
const privKey = hex.decode('0101010101010101010101010101010101010101010101010101010101010101');
// setup taproot tx
const pubS = secp256k1_schnorr.getPublicKey(privKey);
const tx = new btc.Transaction(opts);
for (const inp of TX_TEST_INPUTS) {
const tr = btc.p2tr(pubS);
tx.addInput({
...inp,
...tr,
witnessUtxo: { script: tr.script, amount: inp.amount },
});
}
const txWithtapBip32 = btc.Transaction.fromPSBT(
hex.decode(
'70736274ff010052020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff0148e6052a01000000160014768e1eeb4cf420866033f80aceff0f9720744969000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a07572116fe349064c98d6e2a853fa3c9b12bd8b304a19c195c60efa7ee2393046d3fa2321900772b2da75600008001000080000000800100000000000000011720fe349064c98d6e2a853fa3c9b12bd8b304a19c195c60efa7ee2393046d3fa232002202036b772a6db74d8753c98a827958de6c78ab3312109f37d3e0304484242ece73d818772b2da7540000800100008000000080000000000000000000'
)
);
tx.updateInput(0, {
tapBip32Derivation: txWithtapBip32.inputs[0].tapBip32Derivation,
});
// 'tapBip32Derivation' can be added
deepStrictEqual(tx.inputs[0], {
txid: hex.decode('c061c23190ed3370ad5206769651eaf6fac6d87d85b5db34e30a74e0c4a6da3e'),
index: 0,
sequence: 4294967295,
tapInternalKey: hex.decode('1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f'),
witnessUtxo: {
script: hex.decode('51208c5db7f797196d6edc4dd7df6048f4ea6b883a6af6af032342088f436543790f'),
amount: 550n,
},
tapBip32Derivation: [
[
hex.decode('fe349064c98d6e2a853fa3c9b12bd8b304a19c195c60efa7ee2393046d3fa232'),
{
hashes: [],
der: { fingerprint: 1999318439, path: [2147483734, 2147483649, 2147483648, 1, 0] },
},
],
],
});
// 'tapBip32Derivation' can be removed
tx.updateInput(0, {
tapBip32Derivation: undefined,
});
deepStrictEqual(tx.inputs[0], {
txid: hex.decode('c061c23190ed3370ad5206769651eaf6fac6d87d85b5db34e30a74e0c4a6da3e'),
index: 0,
sequence: 4294967295,
tapInternalKey: hex.decode('1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f'),
witnessUtxo: {
script: hex.decode('51208c5db7f797196d6edc4dd7df6048f4ea6b883a6af6af032342088f436543790f'),
amount: 550n,
},
});
// re-add & sign
tx.updateInput(0, {
tapBip32Derivation: txWithtapBip32.inputs[0].tapBip32Derivation,
});
deepStrictEqual(tx.inputs[0], {
txid: hex.decode('c061c23190ed3370ad5206769651eaf6fac6d87d85b5db34e30a74e0c4a6da3e'),
index: 0,
sequence: 4294967295,
tapInternalKey: hex.decode('1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f'),
witnessUtxo: {
script: hex.decode('51208c5db7f797196d6edc4dd7df6048f4ea6b883a6af6af032342088f436543790f'),
amount: 550n,
},
tapBip32Derivation: [
[
hex.decode('fe349064c98d6e2a853fa3c9b12bd8b304a19c195c60efa7ee2393046d3fa232'),
{
hashes: [],
der: { fingerprint: 1999318439, path: [2147483734, 2147483649, 2147483648, 1, 0] },
},
],
],
});
// Should be same as without field
for (const [address, amount] of TX_TEST_OUTPUTS) tx.addOutputAddress(address, amount);
tx.sign(privKey, undefined, new Uint8Array(32));
tx.finalize();
deepStrictEqual(
hex.encode(tx.extract()),
'020000000001033edaa6c4e0740ae334dbb5857dd8c6faf6ea5196760652ad7033ed9031c261c00000000000ffffffff0d9ae8a4191b3ba5a2b856c21af0f7a4feb97957ae80725ef38a933c906519a20000000000ffffffffc7a4a37d38c2b0de3d3b3e8d8e8a331977c12532fc2a4632df27a89c311ee2fa0000000000ffffffff030a000000000000001976a91406afd46bcdfd22ef94ac122aa11f241244a37ecc88ac320000000000000017a914a860f76561c85551594c18eecceffaee8c4822d7875d00000000000000160014e8df018c7e326cc253faac7e46cdc51e68542c420140de7efa69aff37822182ccae4675051454cc878510834d5f43b509168b4e02a231333f72a5bd603afdb32597b01fcbf65ef74c224e3d325aed36e93baf4e569800140ef36f29d16b6271789321dfbfcb0226940545af93d36efc4918fa13dfa4a70547ce752d4e0648df2650fc15213def1a507528c215a4f067e54501bd1c1ee1e9001400e2fb03c1a230294a50ec3069e30d80059ef48230f036013724d2db2ba7ce8805af7878fee31c18f993a70e8db3fd520327b421cf63e8984b499c9153c810e0000000000'
);
});

// ESM is broken.
import url from 'node:url';
if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
Expand Down
2 changes: 1 addition & 1 deletion test/bitcoinjs-test/btcjs-taproot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ should(`PSBT P2TR finalizeInput`, () => {
// Remove signatures for leaf's we don't want to finalize
if (tx.inputs[t.index].tapScriptSig) {
tx.inputs[t.index].tapScriptSig = tx.inputs[t.index].tapScriptSig.filter((i) =>
P.equalBytes(i[0].leafHash, hex.decode(t.leafHash))
P.utils.equalBytes(i[0].leafHash, hex.decode(t.leafHash))
);
}
tx.finalize();
Expand Down
2 changes: 1 addition & 1 deletion test/slow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ should('big multisig (ours)', () => {
const regtest = { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4 };

const pkeys = [];
for (let i = 1; i < 1000; i++) pkeys.push(P.U256BE.encode(i));
for (let i = 1; i < 1000; i++) pkeys.push(P.U256BE.encode(BigInt(i)));

const pubs = pkeys.map(secp256k1_schnorr.getPublicKey);
const spend = btc.p2tr(undefined, btc.p2tr_ms(999, pubs), regtest);
Expand Down

0 comments on commit 9df4112

Please sign in to comment.