Skip to content

Commit 98f844b

Browse files
Merge pull request #8653 from BitGo/BTC-0.bip322-fixes
feat(abstract-utxo): enhance BIP-322 verification in wasm-utxo
2 parents e124588 + b6b6166 commit 98f844b

6 files changed

Lines changed: 168 additions & 124 deletions

File tree

modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
1+
import { fixedScriptWallet, bip322 } from '@bitgo/wasm-utxo';
22
import { Triple } from '@bitgo/sdk-core';
33

44
import type { FixedScriptWalletOutput, Output, BitGoPsbt } from '../types';
5+
import type { Bip322Message } from '../../abstractUtxoCoin';
56

67
import type { TransactionExplanationWasm } from './explainTransaction';
78

@@ -49,6 +50,8 @@ interface ExplainPsbtWasmParams {
4950
export interface ExplainedInput<TAmount = bigint> {
5051
address: string;
5152
value: TAmount;
53+
scriptId: fixedScriptWallet.ScriptId | null;
54+
signedBy: { [key: string]: boolean };
5255
}
5356

5457
export interface TransactionExplanationBigInt {
@@ -64,9 +67,28 @@ export interface TransactionExplanationBigInt {
6467
fee: bigint;
6568
}
6669

70+
function getSignedByForInput(
71+
psbt: BitGoPsbt,
72+
inputIndex: number,
73+
walletXpubs: fixedScriptWallet.RootWalletKeys,
74+
replayProtectionPublicKeys: Buffer[],
75+
scriptId: fixedScriptWallet.ScriptId | null
76+
): { [key: string]: boolean } {
77+
if (scriptId !== null) {
78+
return {
79+
user: psbt.verifySignature(inputIndex, walletXpubs.userKey()),
80+
backup: psbt.verifySignature(inputIndex, walletXpubs.backupKey()),
81+
bitgo: psbt.verifySignature(inputIndex, walletXpubs.bitgoKey()),
82+
};
83+
}
84+
return Object.fromEntries(
85+
replayProtectionPublicKeys.map((key, j) => [`replayProtection${j}`, psbt.verifySignature(inputIndex, key)])
86+
);
87+
}
88+
6789
export function explainPsbtWasmBigInt(
6890
psbt: BitGoPsbt,
69-
walletXpubs: Triple<string> | fixedScriptWallet.RootWalletKeys,
91+
walletXpubs: fixedScriptWallet.RootWalletKeys,
7092
params: ExplainPsbtWasmParams
7193
): TransactionExplanationBigInt {
7294
const parsed = psbt.parseTransactionWithWalletKeys(walletXpubs, { replayProtection: params.replayProtection });
@@ -92,7 +114,12 @@ export function explainPsbtWasmBigInt(
92114
}
93115
});
94116

95-
const inputs = parsed.inputs.map((input) => ({ address: input.address, value: input.value }));
117+
const inputs = parsed.inputs.map((input, i) => ({
118+
address: input.address,
119+
value: input.value,
120+
scriptId: input.scriptId,
121+
signedBy: getSignedByForInput(psbt, i, walletXpubs, params.replayProtection.publicKeys, input.scriptId),
122+
}));
96123
const inputAmount = inputs.reduce((sum, input) => sum + input.value, 0n);
97124
const outputAmount = outputs.reduce((sum, output) => sum + output.amount, 0n);
98125
const changeAmount = changeOutputs.reduce((sum, output) => sum + output.amount, 0n);
@@ -120,15 +147,32 @@ function stringifyChangeOutput(output: FixedScriptWalletOutput<bigint>): FixedSc
120147
return { ...output, amount: output.amount.toString() };
121148
}
122149

150+
function extractBip322Messages(psbt: BitGoPsbt, inputs: ExplainedInput[]): { messages: Bip322Message[] | undefined } {
151+
const messages: Bip322Message[] = inputs.flatMap((input, i) => {
152+
const message = bip322.getBip322Message(psbt, i);
153+
return message ? [{ message, address: input.address }] : [];
154+
});
155+
156+
if (messages.length === 0) {
157+
return { messages: undefined };
158+
}
159+
160+
return { messages };
161+
}
162+
123163
export function explainPsbtWasm(
124164
psbt: BitGoPsbt,
125165
walletXpubs: Triple<string> | fixedScriptWallet.RootWalletKeys,
126166
params: ExplainPsbtWasmParams
127167
): TransactionExplanationWasm {
128-
const result = explainPsbtWasmBigInt(psbt, walletXpubs, params);
168+
const result = explainPsbtWasmBigInt(psbt, fixedScriptWallet.RootWalletKeys.from(walletXpubs), params);
169+
const inputs = result.inputs.map((i) => ({ address: i.address, value: i.value.toString(), signedBy: i.signedBy }));
170+
171+
const { messages } = extractBip322Messages(psbt, result.inputs);
172+
129173
return {
130174
id: result.id,
131-
inputs: result.inputs.map((i) => ({ address: i.address, value: i.value.toString() })),
175+
inputs,
132176
inputAmount: result.inputAmount.toString(),
133177
outputAmount: result.outputAmount.toString(),
134178
changeAmount: result.changeAmount.toString(),
@@ -137,6 +181,7 @@ export function explainPsbtWasm(
137181
changeOutputs: result.changeOutputs.map(stringifyChangeOutput),
138182
customChangeOutputs: result.customChangeOutputs.map(stringifyChangeOutput),
139183
fee: result.fee.toString(),
184+
messages,
140185
};
141186
}
142187

modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ interface TransactionExplanationWithSignatures<TFee = string, TChangeOutput exte
5252

5353
/** For our wasm backend, we do not return the deprecated fields. We set TFee to string for backwards compatibility. */
5454
export type TransactionExplanationWasm = AbstractUtxoTransactionExplanation<string, FixedScriptWalletOutput> & {
55-
inputs: Array<{ address: string; value: string }>;
55+
inputs: Array<{ address: string; value: string; signedBy: { [key: string]: boolean } }>;
5656
inputAmount: string;
5757
};
5858

modules/abstract-utxo/test/unit/bip322.ts

Lines changed: 111 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import assert from 'assert';
33
import * as utxolib from '@bitgo/utxo-lib';
44
import { bip322 as coreBip322 } from '@bitgo/utxo-core';
55
import { bip322 as wasmBip322, fixedScriptWallet, BIP32, type Triple } from '@bitgo/wasm-utxo';
6+
import { getKeyTriple } from '@bitgo/wasm-utxo/testutils';
67

8+
import { explainPsbtWasm } from '../../src/transaction/fixedScript';
79
import {
810
BIP322MessageBroadcastable,
911
BIP322MessageInfo,
@@ -17,20 +19,22 @@ function createTestWalletKeys(seed: string): {
1719
xpubs: Triple<string>;
1820
xprivs: Triple<string>;
1921
} {
20-
const keys = utxolib.testutil.getKeyTriple(seed);
22+
const keys = getKeyTriple(seed);
2123
return {
2224
xpubs: keys.map((k) => k.neutered().toBase58()) as Triple<string>,
2325
xprivs: keys.map((k) => k.toBase58()) as Triple<string>,
2426
};
2527
}
2628

2729
function getDerivedPubkeys(seed: string, chain: number, index: number): Triple<string> {
28-
const keys = utxolib.testutil.getKeyTriple(seed);
29-
return keys.map((k) => k.derivePath(`m/0/0/${chain}/${index}`).publicKey.toString('hex')) as Triple<string>;
30+
const keys = getKeyTriple(seed);
31+
return keys.map((k) =>
32+
Buffer.from(k.derivePath(`m/0/0/${chain}/${index}`).publicKey).toString('hex')
33+
) as Triple<string>;
3034
}
3135

3236
function getAddress(walletKeys: fixedScriptWallet.RootWalletKeys, chain: number, index: number): string {
33-
return fixedScriptWallet.address(walletKeys, chain, index, utxolib.networks.bitcoin);
37+
return fixedScriptWallet.address(walletKeys, chain, index, 'btc');
3438
}
3539

3640
describe('BIP322', function () {
@@ -376,8 +380,8 @@ describe('BIP322', function () {
376380
scriptId: { chain, index },
377381
rootWalletKeys: walletKeys,
378382
});
379-
psbt.sign(0, BIP32.fromBase58(xprivs[0]));
380-
psbt.sign(0, BIP32.fromBase58(xprivs[2]));
383+
psbt.sign(BIP32.fromBase58(xprivs[0]));
384+
psbt.sign(BIP32.fromBase58(xprivs[2]));
381385

382386
const pubkeys = getDerivedPubkeys(seed, chain, index);
383387
const address = getAddress(walletKeys, chain, index);
@@ -403,18 +407,100 @@ describe('BIP322', function () {
403407
});
404408
});
405409

406-
describe('utxolib verification stack - wasm-utxo respects input.sighashType', function () {
407-
// This test verifies that wasm-utxo correctly respects the input.sighashType field
408-
// when creating musig2 partial signatures.
409-
//
410-
// Previously (before fix), wasm-utxo would always create signatures with SIGHASH_DEFAULT (0)
411-
// regardless of the input.sighashType field, causing validation to fail.
412-
//
413-
// Now (after fix), wasm-utxo reads input.sighashType and creates signatures with the
414-
// correct sighash type, allowing validation to succeed.
410+
describe('BIP322 Proof', function () {
411+
const message = 'I can believe it is not butter';
412+
const chain = 10;
413+
const index = 0;
414+
const { xpubs, xprivs } = createTestWalletKeys('bip322-proof');
415+
const walletKeys = fixedScriptWallet.RootWalletKeys.from(xpubs);
416+
417+
function createUnsignedPsbt(): fixedScriptWallet.BitGoPsbt {
418+
const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('btc', walletKeys, { version: 0 });
419+
wasmBip322.addBip322Input(psbt, { message, scriptId: { chain, index }, rootWalletKeys: walletKeys });
420+
return psbt;
421+
}
422+
423+
function assertCommon(result: ReturnType<typeof explainPsbtWasm>, expectedSignerCount: number): void {
424+
assert.strictEqual(result.outputAmount, '0');
425+
assert.strictEqual(result.changeAmount, '0');
426+
assert.strictEqual(result.outputs.length, 1);
427+
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
428+
assert.strictEqual(result.fee, '0');
429+
for (const input of result.inputs) {
430+
const signerCount = Object.values(input.signedBy).filter(Boolean).length;
431+
assert.strictEqual(signerCount, expectedSignerCount);
432+
}
433+
assert.ok(result.messages);
434+
for (const obj of result.messages ?? []) {
435+
assert.ok(obj.address);
436+
assert.strictEqual(obj.message, message);
437+
}
438+
}
439+
440+
it('should successfully run with a user nonce', function () {
441+
const psbt = createUnsignedPsbt();
442+
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 0);
443+
});
415444

416-
it('should validate signatures when wasm-utxo respects input.sighashType', function () {
445+
it('should successfully run with a user signature', function () {
446+
const psbt = createUnsignedPsbt();
447+
psbt.sign(BIP32.fromBase58(xprivs[0]));
448+
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 1);
449+
});
450+
451+
it('should successfully run with a hsm signature', function () {
452+
const psbt = createUnsignedPsbt();
453+
psbt.sign(BIP32.fromBase58(xprivs[0]));
454+
psbt.sign(BIP32.fromBase58(xprivs[2]));
455+
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 2);
456+
});
457+
});
458+
459+
describe('p2trMusig2 BIP322 signing', function () {
460+
it('should produce verifiable musig2 signatures', function () {
417461
const seed = 'p2trMusig2_sighash_test';
462+
const { xpubs, xprivs } = createTestWalletKeys(seed);
463+
const walletKeys = fixedScriptWallet.RootWalletKeys.from(xpubs);
464+
465+
const chain = 40; // p2trMusig2 external
466+
const index = 0;
467+
const messageText = 'BIP322 sighash test';
468+
469+
const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('btc', walletKeys, { version: 0 });
470+
wasmBip322.addBip322Input(psbt, {
471+
message: messageText,
472+
scriptId: { chain, index },
473+
rootWalletKeys: walletKeys,
474+
signPath: { signer: 'user', cosigner: 'bitgo' },
475+
});
476+
477+
const userKey = BIP32.fromBase58(xprivs[0]);
478+
const bitgoKey = BIP32.fromBase58(xprivs[2]);
479+
psbt.generateMusig2Nonces(userKey);
480+
psbt.generateMusig2Nonces(bitgoKey);
481+
psbt.sign(userKey);
482+
psbt.sign(bitgoKey);
483+
484+
const signers = wasmBip322.verifyBip322PsbtInput(psbt, 0, {
485+
message: messageText,
486+
scriptId: { chain, index },
487+
rootWalletKeys: walletKeys,
488+
});
489+
assert.ok(signers.includes('user'));
490+
assert.ok(signers.includes('bitgo'));
491+
});
492+
});
493+
494+
describe('utxolib interoperability - wasm-utxo can verify utxolib-generated BIP322 proofs', function () {
495+
// This test verifies cross-library compatibility:
496+
// 1. utxo-core (utxolib) creates a BIP322 PSBT
497+
// 2. wasm-utxo signs it with musig2
498+
// 3. utxo-core validates the wasm-utxo signatures
499+
//
500+
// This ensures that wasm-utxo and utxolib generate compatible BIP322 proofs.
501+
502+
it('should sign utxolib-created BIP322 PSBT and validate with utxolib', function () {
503+
const seed = 'p2trMusig2_utxolib_compat_test';
418504
const { xprivs } = createTestWalletKeys(seed);
419505

420506
// Create utxolib RootWalletKeys for utxo-core PSBT construction
@@ -423,37 +509,34 @@ describe('BIP322', function () {
423509
// p2trMusig2 external chain code
424510
const chain = utxolib.bitgo.getExternalChainCode('p2trMusig2');
425511
const index = 0;
426-
const messageText = 'BIP322 sighash test';
512+
const messageText = 'BIP322 utxolib interop test';
427513

428514
// Create BIP322 PSBT using utxo-core
429515
const psbt = coreBip322.createBaseToSignPsbt(utxolibRootWalletKeys, utxolib.networks.bitcoin);
430-
coreBip322.addBip322InputWithChainAndIndex(psbt, messageText, utxolibRootWalletKeys, { chain, index });
431-
432-
// Note: utxo-core sets sighashType: Transaction.SIGHASH_ALL (1) for BIP322 inputs
433-
const SIGHASH_ALL = 1;
434-
assert.strictEqual(psbt.data.inputs[0].sighashType, SIGHASH_ALL);
516+
coreBip322.addBip322InputWithChainAndIndex(psbt, messageText, utxolibRootWalletKeys, {
517+
chain,
518+
index,
519+
});
435520

436-
// Convert to wasm-utxo PSBT for cosigning
521+
// Convert to wasm-utxo PSBT for signing
437522
const wasmPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbt.toBuffer(), 'btc');
438523

439524
// Generate musig2 nonces and sign with wasm-utxo
440-
// wasm-utxo now respects input.sighashType and creates signatures with SIGHASH_ALL
441525
const userKey = BIP32.fromBase58(xprivs[0]);
442526
const bitgoKey = BIP32.fromBase58(xprivs[2]);
443527

444528
wasmPsbt.generateMusig2Nonces(userKey);
445529
wasmPsbt.generateMusig2Nonces(bitgoKey);
446-
wasmPsbt.sign(0, userKey);
447-
wasmPsbt.sign(0, bitgoKey);
530+
wasmPsbt.sign(userKey);
531+
wasmPsbt.sign(bitgoKey);
448532

449533
// Convert back to utxolib PSBT for validation
450534
const signedPsbt = utxolib.bitgo.createPsbtFromBuffer(
451535
Buffer.from(wasmPsbt.serialize()),
452536
utxolib.networks.bitcoin
453537
);
454538

455-
// Validation should succeed because wasm-utxo now creates signatures
456-
// with the correct sighash type (SIGHASH_ALL) matching input.sighashType
539+
// Validation should succeed - wasm-utxo signatures are compatible with utxolib
457540
const validationResult = utxolib.bitgo.getSignatureValidationArrayPsbt(signedPsbt, utxolibRootWalletKeys);
458541

459542
// Verify that both user (index 0) and bitgo (index 2) signatures are valid
Lines changed: 0 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
import assert from 'assert';
2-
31
import { common, Triple, Wallet } from '@bitgo/sdk-core';
42
import nock = require('nock');
53

6-
import { bip322Fixtures } from './fixtures/bip322/fixtures';
74
import { psbtTxHex } from './fixtures/psbtHexProof';
85
import { defaultBitGo, getUtxoCoin } from './util';
96

@@ -43,63 +40,4 @@ describe('Explain Transaction', function () {
4340
await coin.explainTransaction(psbtTxHex, wallet);
4441
});
4542
});
46-
47-
describe('BIP322 Proof', function () {
48-
const coin = getUtxoCoin('btc');
49-
const pubs = bip322Fixtures.valid.rootWalletKeys.triple.map((b) => b.neutered().toBase58()) as Triple<string>;
50-
51-
it('should successfully run with a user nonce', async function () {
52-
const psbtHex = bip322Fixtures.valid.userNonce;
53-
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
54-
assert.strictEqual(result.outputAmount, '0');
55-
assert.strictEqual(result.changeAmount, '0');
56-
assert.strictEqual(result.outputs.length, 1);
57-
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
58-
assert.strictEqual(result.fee, '0');
59-
assert.ok('signatures' in result);
60-
assert.strictEqual(result.signatures, 0);
61-
assert.ok(result.messages);
62-
result.messages?.forEach((obj) => {
63-
assert.ok(obj.address);
64-
assert.ok(obj.message);
65-
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
66-
});
67-
});
68-
69-
it('should successfully run with a user signature', async function () {
70-
const psbtHex = bip322Fixtures.valid.userSignature;
71-
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
72-
assert.strictEqual(result.outputAmount, '0');
73-
assert.strictEqual(result.changeAmount, '0');
74-
assert.strictEqual(result.outputs.length, 1);
75-
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
76-
assert.strictEqual(result.fee, '0');
77-
assert.ok('signatures' in result);
78-
assert.strictEqual(result.signatures, 1);
79-
assert.ok(result.messages);
80-
result.messages?.forEach((obj) => {
81-
assert.ok(obj.address);
82-
assert.ok(obj.message);
83-
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
84-
});
85-
});
86-
87-
it('should successfully run with a hsm signature', async function () {
88-
const psbtHex = bip322Fixtures.valid.hsmSignature;
89-
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
90-
assert.strictEqual(result.outputAmount, '0');
91-
assert.strictEqual(result.changeAmount, '0');
92-
assert.strictEqual(result.outputs.length, 1);
93-
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
94-
assert.strictEqual(result.fee, '0');
95-
assert.ok('signatures' in result);
96-
assert.strictEqual(result.signatures, 2);
97-
assert.ok(result.messages);
98-
result.messages?.forEach((obj) => {
99-
assert.ok(obj.address);
100-
assert.ok(obj.message);
101-
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
102-
});
103-
});
104-
});
10543
});

0 commit comments

Comments
 (0)