Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
import { fixedScriptWallet, bip322 } from '@bitgo/wasm-utxo';
import { Triple } from '@bitgo/sdk-core';

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

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

Expand Down Expand Up @@ -49,6 +50,8 @@ interface ExplainPsbtWasmParams {
export interface ExplainedInput<TAmount = bigint> {
address: string;
value: TAmount;
scriptId: fixedScriptWallet.ScriptId | null;
signedBy: { [key: string]: boolean };
}

export interface TransactionExplanationBigInt {
Expand All @@ -64,9 +67,28 @@ export interface TransactionExplanationBigInt {
fee: bigint;
}

function getSignedByForInput(
psbt: BitGoPsbt,
inputIndex: number,
walletXpubs: fixedScriptWallet.RootWalletKeys,
replayProtectionPublicKeys: Buffer[],
scriptId: fixedScriptWallet.ScriptId | null
): { [key: string]: boolean } {
if (scriptId !== null) {
return {
user: psbt.verifySignature(inputIndex, walletXpubs.userKey()),
backup: psbt.verifySignature(inputIndex, walletXpubs.backupKey()),
bitgo: psbt.verifySignature(inputIndex, walletXpubs.bitgoKey()),
};
}
return Object.fromEntries(
replayProtectionPublicKeys.map((key, j) => [`replayProtection${j}`, psbt.verifySignature(inputIndex, key)])
);
}

export function explainPsbtWasmBigInt(
psbt: BitGoPsbt,
walletXpubs: Triple<string> | fixedScriptWallet.RootWalletKeys,
walletXpubs: fixedScriptWallet.RootWalletKeys,
params: ExplainPsbtWasmParams
): TransactionExplanationBigInt {
const parsed = psbt.parseTransactionWithWalletKeys(walletXpubs, { replayProtection: params.replayProtection });
Expand All @@ -92,7 +114,12 @@ export function explainPsbtWasmBigInt(
}
});

const inputs = parsed.inputs.map((input) => ({ address: input.address, value: input.value }));
const inputs = parsed.inputs.map((input, i) => ({
address: input.address,
value: input.value,
scriptId: input.scriptId,
signedBy: getSignedByForInput(psbt, i, walletXpubs, params.replayProtection.publicKeys, input.scriptId),
}));
const inputAmount = inputs.reduce((sum, input) => sum + input.value, 0n);
const outputAmount = outputs.reduce((sum, output) => sum + output.amount, 0n);
const changeAmount = changeOutputs.reduce((sum, output) => sum + output.amount, 0n);
Expand Down Expand Up @@ -120,15 +147,32 @@ function stringifyChangeOutput(output: FixedScriptWalletOutput<bigint>): FixedSc
return { ...output, amount: output.amount.toString() };
}

function extractBip322Messages(psbt: BitGoPsbt, inputs: ExplainedInput[]): { messages: Bip322Message[] | undefined } {
const messages: Bip322Message[] = inputs.flatMap((input, i) => {
const message = bip322.getBip322Message(psbt, i);
return message ? [{ message, address: input.address }] : [];
});

if (messages.length === 0) {
return { messages: undefined };
}

return { messages };
}

export function explainPsbtWasm(
psbt: BitGoPsbt,
walletXpubs: Triple<string> | fixedScriptWallet.RootWalletKeys,
params: ExplainPsbtWasmParams
): TransactionExplanationWasm {
const result = explainPsbtWasmBigInt(psbt, walletXpubs, params);
const result = explainPsbtWasmBigInt(psbt, fixedScriptWallet.RootWalletKeys.from(walletXpubs), params);
const inputs = result.inputs.map((i) => ({ address: i.address, value: i.value.toString(), signedBy: i.signedBy }));

const { messages } = extractBip322Messages(psbt, result.inputs);

return {
id: result.id,
inputs: result.inputs.map((i) => ({ address: i.address, value: i.value.toString() })),
inputs,
inputAmount: result.inputAmount.toString(),
outputAmount: result.outputAmount.toString(),
changeAmount: result.changeAmount.toString(),
Expand All @@ -137,6 +181,7 @@ export function explainPsbtWasm(
changeOutputs: result.changeOutputs.map(stringifyChangeOutput),
customChangeOutputs: result.customChangeOutputs.map(stringifyChangeOutput),
fee: result.fee.toString(),
messages,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ interface TransactionExplanationWithSignatures<TFee = string, TChangeOutput exte

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

Expand Down
139 changes: 111 additions & 28 deletions modules/abstract-utxo/test/unit/bip322.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import assert from 'assert';
import * as utxolib from '@bitgo/utxo-lib';
import { bip322 as coreBip322 } from '@bitgo/utxo-core';
import { bip322 as wasmBip322, fixedScriptWallet, BIP32, type Triple } from '@bitgo/wasm-utxo';
import { getKeyTriple } from '@bitgo/wasm-utxo/testutils';

import { explainPsbtWasm } from '../../src/transaction/fixedScript';
import {
BIP322MessageBroadcastable,
BIP322MessageInfo,
Expand All @@ -17,20 +19,22 @@ function createTestWalletKeys(seed: string): {
xpubs: Triple<string>;
xprivs: Triple<string>;
} {
const keys = utxolib.testutil.getKeyTriple(seed);
const keys = getKeyTriple(seed);
return {
xpubs: keys.map((k) => k.neutered().toBase58()) as Triple<string>,
xprivs: keys.map((k) => k.toBase58()) as Triple<string>,
};
}

function getDerivedPubkeys(seed: string, chain: number, index: number): Triple<string> {
const keys = utxolib.testutil.getKeyTriple(seed);
return keys.map((k) => k.derivePath(`m/0/0/${chain}/${index}`).publicKey.toString('hex')) as Triple<string>;
const keys = getKeyTriple(seed);
return keys.map((k) =>
Buffer.from(k.derivePath(`m/0/0/${chain}/${index}`).publicKey).toString('hex')
) as Triple<string>;
}

function getAddress(walletKeys: fixedScriptWallet.RootWalletKeys, chain: number, index: number): string {
return fixedScriptWallet.address(walletKeys, chain, index, utxolib.networks.bitcoin);
return fixedScriptWallet.address(walletKeys, chain, index, 'btc');
}

describe('BIP322', function () {
Expand Down Expand Up @@ -376,8 +380,8 @@ describe('BIP322', function () {
scriptId: { chain, index },
rootWalletKeys: walletKeys,
});
psbt.sign(0, BIP32.fromBase58(xprivs[0]));
psbt.sign(0, BIP32.fromBase58(xprivs[2]));
psbt.sign(BIP32.fromBase58(xprivs[0]));
psbt.sign(BIP32.fromBase58(xprivs[2]));

const pubkeys = getDerivedPubkeys(seed, chain, index);
const address = getAddress(walletKeys, chain, index);
Expand All @@ -403,18 +407,100 @@ describe('BIP322', function () {
});
});

describe('utxolib verification stack - wasm-utxo respects input.sighashType', function () {
// This test verifies that wasm-utxo correctly respects the input.sighashType field
// when creating musig2 partial signatures.
//
// Previously (before fix), wasm-utxo would always create signatures with SIGHASH_DEFAULT (0)
// regardless of the input.sighashType field, causing validation to fail.
//
// Now (after fix), wasm-utxo reads input.sighashType and creates signatures with the
// correct sighash type, allowing validation to succeed.
describe('BIP322 Proof', function () {
const message = 'I can believe it is not butter';
const chain = 10;
const index = 0;
const { xpubs, xprivs } = createTestWalletKeys('bip322-proof');
const walletKeys = fixedScriptWallet.RootWalletKeys.from(xpubs);

function createUnsignedPsbt(): fixedScriptWallet.BitGoPsbt {
const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('btc', walletKeys, { version: 0 });
wasmBip322.addBip322Input(psbt, { message, scriptId: { chain, index }, rootWalletKeys: walletKeys });
return psbt;
}

function assertCommon(result: ReturnType<typeof explainPsbtWasm>, expectedSignerCount: number): void {
assert.strictEqual(result.outputAmount, '0');
assert.strictEqual(result.changeAmount, '0');
assert.strictEqual(result.outputs.length, 1);
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
assert.strictEqual(result.fee, '0');
for (const input of result.inputs) {
const signerCount = Object.values(input.signedBy).filter(Boolean).length;
assert.strictEqual(signerCount, expectedSignerCount);
}
assert.ok(result.messages);
for (const obj of result.messages ?? []) {
assert.ok(obj.address);
assert.strictEqual(obj.message, message);
}
}

it('should successfully run with a user nonce', function () {
const psbt = createUnsignedPsbt();
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 0);
});

it('should validate signatures when wasm-utxo respects input.sighashType', function () {
it('should successfully run with a user signature', function () {
const psbt = createUnsignedPsbt();
psbt.sign(BIP32.fromBase58(xprivs[0]));
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 1);
});

it('should successfully run with a hsm signature', function () {
const psbt = createUnsignedPsbt();
psbt.sign(BIP32.fromBase58(xprivs[0]));
psbt.sign(BIP32.fromBase58(xprivs[2]));
assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 2);
});
});

describe('p2trMusig2 BIP322 signing', function () {
it('should produce verifiable musig2 signatures', function () {
const seed = 'p2trMusig2_sighash_test';
const { xpubs, xprivs } = createTestWalletKeys(seed);
const walletKeys = fixedScriptWallet.RootWalletKeys.from(xpubs);

const chain = 40; // p2trMusig2 external
const index = 0;
const messageText = 'BIP322 sighash test';

const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('btc', walletKeys, { version: 0 });
wasmBip322.addBip322Input(psbt, {
message: messageText,
scriptId: { chain, index },
rootWalletKeys: walletKeys,
signPath: { signer: 'user', cosigner: 'bitgo' },
});

const userKey = BIP32.fromBase58(xprivs[0]);
const bitgoKey = BIP32.fromBase58(xprivs[2]);
psbt.generateMusig2Nonces(userKey);
psbt.generateMusig2Nonces(bitgoKey);
psbt.sign(userKey);
psbt.sign(bitgoKey);

const signers = wasmBip322.verifyBip322PsbtInput(psbt, 0, {
message: messageText,
scriptId: { chain, index },
rootWalletKeys: walletKeys,
});
assert.ok(signers.includes('user'));
assert.ok(signers.includes('bitgo'));
});
});

describe('utxolib interoperability - wasm-utxo can verify utxolib-generated BIP322 proofs', function () {
// This test verifies cross-library compatibility:
// 1. utxo-core (utxolib) creates a BIP322 PSBT
// 2. wasm-utxo signs it with musig2
// 3. utxo-core validates the wasm-utxo signatures
//
// This ensures that wasm-utxo and utxolib generate compatible BIP322 proofs.

it('should sign utxolib-created BIP322 PSBT and validate with utxolib', function () {
const seed = 'p2trMusig2_utxolib_compat_test';
const { xprivs } = createTestWalletKeys(seed);

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

// Create BIP322 PSBT using utxo-core
const psbt = coreBip322.createBaseToSignPsbt(utxolibRootWalletKeys, utxolib.networks.bitcoin);
coreBip322.addBip322InputWithChainAndIndex(psbt, messageText, utxolibRootWalletKeys, { chain, index });

// Note: utxo-core sets sighashType: Transaction.SIGHASH_ALL (1) for BIP322 inputs
const SIGHASH_ALL = 1;
assert.strictEqual(psbt.data.inputs[0].sighashType, SIGHASH_ALL);
coreBip322.addBip322InputWithChainAndIndex(psbt, messageText, utxolibRootWalletKeys, {
chain,
index,
});

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

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

wasmPsbt.generateMusig2Nonces(userKey);
wasmPsbt.generateMusig2Nonces(bitgoKey);
wasmPsbt.sign(0, userKey);
wasmPsbt.sign(0, bitgoKey);
wasmPsbt.sign(userKey);
wasmPsbt.sign(bitgoKey);

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

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

// Verify that both user (index 0) and bitgo (index 2) signatures are valid
Expand Down
62 changes: 0 additions & 62 deletions modules/abstract-utxo/test/unit/explainTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import assert from 'assert';

import { common, Triple, Wallet } from '@bitgo/sdk-core';
import nock = require('nock');

import { bip322Fixtures } from './fixtures/bip322/fixtures';
import { psbtTxHex } from './fixtures/psbtHexProof';
import { defaultBitGo, getUtxoCoin } from './util';

Expand Down Expand Up @@ -43,63 +40,4 @@ describe('Explain Transaction', function () {
await coin.explainTransaction(psbtTxHex, wallet);
});
});

describe('BIP322 Proof', function () {
const coin = getUtxoCoin('btc');
const pubs = bip322Fixtures.valid.rootWalletKeys.triple.map((b) => b.neutered().toBase58()) as Triple<string>;

it('should successfully run with a user nonce', async function () {
const psbtHex = bip322Fixtures.valid.userNonce;
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
assert.strictEqual(result.outputAmount, '0');
assert.strictEqual(result.changeAmount, '0');
assert.strictEqual(result.outputs.length, 1);
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
assert.strictEqual(result.fee, '0');
assert.ok('signatures' in result);
assert.strictEqual(result.signatures, 0);
assert.ok(result.messages);
result.messages?.forEach((obj) => {
assert.ok(obj.address);
assert.ok(obj.message);
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
});
});

it('should successfully run with a user signature', async function () {
const psbtHex = bip322Fixtures.valid.userSignature;
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
assert.strictEqual(result.outputAmount, '0');
assert.strictEqual(result.changeAmount, '0');
assert.strictEqual(result.outputs.length, 1);
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
assert.strictEqual(result.fee, '0');
assert.ok('signatures' in result);
assert.strictEqual(result.signatures, 1);
assert.ok(result.messages);
result.messages?.forEach((obj) => {
assert.ok(obj.address);
assert.ok(obj.message);
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
});
});

it('should successfully run with a hsm signature', async function () {
const psbtHex = bip322Fixtures.valid.hsmSignature;
const result = await coin.explainTransaction({ txHex: psbtHex, pubs });
assert.strictEqual(result.outputAmount, '0');
assert.strictEqual(result.changeAmount, '0');
assert.strictEqual(result.outputs.length, 1);
assert.strictEqual(result.outputs[0].address, 'scriptPubKey:6a');
assert.strictEqual(result.fee, '0');
assert.ok('signatures' in result);
assert.strictEqual(result.signatures, 2);
assert.ok(result.messages);
result.messages?.forEach((obj) => {
assert.ok(obj.address);
assert.ok(obj.message);
assert.strictEqual(obj.message, bip322Fixtures.valid.message);
});
});
});
});
Loading
Loading