Skip to content

Commit b6b6166

Browse files
OttoAllmendingerllm-git
andcommitted
fix(abstract-utxo): enhance BIP-322 with signature verification
Extend explainPsbtWasm to count valid signatures per input: - Add scriptId to ExplainedInput (from parseTransactionWithWalletKeys) - Add signedBy map showing validation status for each key - Extract BIP-322 messages with bip322.getBip322Message() - Count signatures via bip322.verifyBip322PsbtInput() Replace BIP-322 test fixtures with dynamic PSBT generation using wasm-utxo. Remove fixtures/bip322/fixtures.ts and inline PSBT construction directly in tests. Call explainPsbtWasm with BitGoPsbt instances instead of serializing via coin.explainTransaction. Remove utxolib dependency from BIP-322 tests. Use @bitgo/wasm-utxo directly for key generation, address creation, and signature verification. Rewrite p2trMusig2 sighashType test as pure wasm-utxo round-trip. Add cross-library compatibility test verifying utxolib-created BIP-322 PSBTs can be signed by wasm-utxo and validated by utxolib. Issue: BTC-2650 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent d1e2a12 commit b6b6166

6 files changed

Lines changed: 151 additions & 108 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: 94 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +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

7-
import { bip322Fixtures } from './fixtures/bip322/fixtures';
8-
import { getUtxoCoin } from './util';
9-
8+
import { explainPsbtWasm } from '../../src/transaction/fixedScript';
109
import {
1110
BIP322MessageBroadcastable,
1211
BIP322MessageInfo,
@@ -20,20 +19,22 @@ function createTestWalletKeys(seed: string): {
2019
xpubs: Triple<string>;
2120
xprivs: Triple<string>;
2221
} {
23-
const keys = utxolib.testutil.getKeyTriple(seed);
22+
const keys = getKeyTriple(seed);
2423
return {
2524
xpubs: keys.map((k) => k.neutered().toBase58()) as Triple<string>,
2625
xprivs: keys.map((k) => k.toBase58()) as Triple<string>,
2726
};
2827
}
2928

3029
function getDerivedPubkeys(seed: string, chain: number, index: number): Triple<string> {
31-
const keys = utxolib.testutil.getKeyTriple(seed);
32-
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>;
3334
}
3435

3536
function getAddress(walletKeys: fixedScriptWallet.RootWalletKeys, chain: number, index: number): string {
36-
return fixedScriptWallet.address(walletKeys, chain, index, utxolib.networks.bitcoin);
37+
return fixedScriptWallet.address(walletKeys, chain, index, 'btc');
3738
}
3839

3940
describe('BIP322', function () {
@@ -379,8 +380,8 @@ describe('BIP322', function () {
379380
scriptId: { chain, index },
380381
rootWalletKeys: walletKeys,
381382
});
382-
psbt.sign(0, BIP32.fromBase58(xprivs[0]));
383-
psbt.sign(0, BIP32.fromBase58(xprivs[2]));
383+
psbt.sign(BIP32.fromBase58(xprivs[0]));
384+
psbt.sign(BIP32.fromBase58(xprivs[2]));
384385

385386
const pubkeys = getDerivedPubkeys(seed, chain, index);
386387
const address = getAddress(walletKeys, chain, index);
@@ -407,76 +408,99 @@ describe('BIP322', function () {
407408
});
408409

409410
describe('BIP322 Proof', function () {
410-
const coin = getUtxoCoin('btc');
411-
const pubs = bip322Fixtures.valid.rootWalletKeys.triple.map((b) => b.neutered().toBase58()) as Triple<string>;
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);
412416

413-
it('should successfully run with a user nonce', async function () {
414-
const psbtHex = bip322Fixtures.valid.userNonce;
415-
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
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 {
416424
assert.strictEqual(result.outputAmount, '0');
417425
assert.strictEqual(result.changeAmount, '0');
418426
assert.strictEqual(result.outputs.length, 1);
419427
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
420428
assert.strictEqual(result.fee, '0');
421-
assert.ok('signatures' in result);
422-
assert.strictEqual(result.signatures, 0);
429+
for (const input of result.inputs) {
430+
const signerCount = Object.values(input.signedBy).filter(Boolean).length;
431+
assert.strictEqual(signerCount, expectedSignerCount);
432+
}
423433
assert.ok(result.messages);
424-
result.messages?.forEach((obj) => {
434+
for (const obj of result.messages ?? []) {
425435
assert.ok(obj.address);
426-
assert.ok(obj.message);
427-
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
428-
});
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);
429443
});
430444

431-
it('should successfully run with a user signature', async function () {
432-
const psbtHex = bip322Fixtures.valid.userSignature;
433-
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
434-
assert.strictEqual(result.outputAmount, '0');
435-
assert.strictEqual(result.changeAmount, '0');
436-
assert.strictEqual(result.outputs.length, 1);
437-
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
438-
assert.strictEqual(result.fee, '0');
439-
assert.ok('signatures' in result);
440-
assert.strictEqual(result.signatures, 1);
441-
assert.ok(result.messages);
442-
result.messages?.forEach((obj) => {
443-
assert.ok(obj.address);
444-
assert.ok(obj.message);
445-
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
446-
});
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);
447449
});
448450

449-
it('should successfully run with a hsm signature', async function () {
450-
const psbtHex = bip322Fixtures.valid.hsmSignature;
451-
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
452-
assert.strictEqual(result.outputAmount, '0');
453-
assert.strictEqual(result.changeAmount, '0');
454-
assert.strictEqual(result.outputs.length, 1);
455-
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
456-
assert.strictEqual(result.fee, '0');
457-
assert.ok('signatures' in result);
458-
assert.strictEqual(result.signatures, 2);
459-
assert.ok(result.messages);
460-
result.messages?.forEach((obj) => {
461-
assert.ok(obj.address);
462-
assert.ok(obj.message);
463-
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
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 () {
461+
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' },
464475
});
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'));
465491
});
466492
});
467493

468-
describe('utxolib verification stack - wasm-utxo respects input.sighashType', function () {
469-
// This test verifies that wasm-utxo correctly respects the input.sighashType field
470-
// when creating musig2 partial signatures.
471-
//
472-
// Previously (before fix), wasm-utxo would always create signatures with SIGHASH_DEFAULT (0)
473-
// regardless of the input.sighashType field, causing validation to fail.
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
474499
//
475-
// Now (after fix), wasm-utxo reads input.sighashType and creates signatures with the
476-
// correct sighash type, allowing validation to succeed.
500+
// This ensures that wasm-utxo and utxolib generate compatible BIP322 proofs.
477501

478-
it('should validate signatures when wasm-utxo respects input.sighashType', function () {
479-
const seed = 'p2trMusig2_sighash_test';
502+
it('should sign utxolib-created BIP322 PSBT and validate with utxolib', function () {
503+
const seed = 'p2trMusig2_utxolib_compat_test';
480504
const { xprivs } = createTestWalletKeys(seed);
481505

482506
// Create utxolib RootWalletKeys for utxo-core PSBT construction
@@ -485,37 +509,34 @@ describe('BIP322', function () {
485509
// p2trMusig2 external chain code
486510
const chain = utxolib.bitgo.getExternalChainCode('p2trMusig2');
487511
const index = 0;
488-
const messageText = 'BIP322 sighash test';
512+
const messageText = 'BIP322 utxolib interop test';
489513

490514
// Create BIP322 PSBT using utxo-core
491515
const psbt = coreBip322.createBaseToSignPsbt(utxolibRootWalletKeys, utxolib.networks.bitcoin);
492-
coreBip322.addBip322InputWithChainAndIndex(psbt, messageText, utxolibRootWalletKeys, { chain, index });
493-
494-
// Note: utxo-core sets sighashType: Transaction.SIGHASH_ALL (1) for BIP322 inputs
495-
const SIGHASH_ALL = 1;
496-
assert.strictEqual(psbt.data.inputs[0].sighashType, SIGHASH_ALL);
516+
coreBip322.addBip322InputWithChainAndIndex(psbt, messageText, utxolibRootWalletKeys, {
517+
chain,
518+
index,
519+
});
497520

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

501524
// Generate musig2 nonces and sign with wasm-utxo
502-
// wasm-utxo now respects input.sighashType and creates signatures with SIGHASH_ALL
503525
const userKey = BIP32.fromBase58(xprivs[0]);
504526
const bitgoKey = BIP32.fromBase58(xprivs[2]);
505527

506528
wasmPsbt.generateMusig2Nonces(userKey);
507529
wasmPsbt.generateMusig2Nonces(bitgoKey);
508-
wasmPsbt.sign(0, userKey);
509-
wasmPsbt.sign(0, bitgoKey);
530+
wasmPsbt.sign(userKey);
531+
wasmPsbt.sign(bitgoKey);
510532

511533
// Convert back to utxolib PSBT for validation
512534
const signedPsbt = utxolib.bitgo.createPsbtFromBuffer(
513535
Buffer.from(wasmPsbt.serialize()),
514536
utxolib.networks.bitcoin
515537
);
516538

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

521542
// Verify that both user (index 0) and bitgo (index 2) signatures are valid

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,4 @@ describe('Explain Transaction', function () {
4040
await coin.explainTransaction(psbtTxHex, wallet);
4141
});
4242
});
43-
4443
});

0 commit comments

Comments
 (0)