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
14 changes: 12 additions & 2 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ import {
} from './names';
import { assertFixedScriptWalletAddress } from './address/fixedScript';
import { isSdkBackend, ParsedTransaction, SdkBackend } from './transaction/types';
import { decodePsbtWith, encodeTransaction, stringToBufferTryFormats } from './transaction/decode';
import {
decodeDescriptorPsbt,
decodePsbtWith,
encodeTransaction,
stringToBufferTryFormats,
} from './transaction/decode';
import { fetchKeychains, toBip32Triple, UtxoKeychain } from './keychains';
import { verifyKeySignature, verifyUserPublicKey } from './verifyKey';
import { getPolicyForEnv } from './descriptor/validatePolicy';
Expand Down Expand Up @@ -963,7 +968,12 @@ export abstract class AbstractUtxoCoin
params.pubs = toBip32Triple(keychains).map((k) => k.neutered().toBase58()) as Triple<string>;
}
}
return explainTx(this.decodeTransactionFromPrebuild(params), params, this.name);
if (wallet && isDescriptorWallet(wallet)) {
// Descriptor wallets decode prebuild bytes straight into the wasm-utxo
// descriptor Psbt, skipping the fixedScriptWallet.BitGoPsbt intermediate.
return explainTx(decodeDescriptorPsbt(params), { ...params, wallet }, this.name);
}
return explainTx(this.decodeTransactionFromPrebuild(params), { ...params, wallet }, this.name);
}

/**
Expand Down
26 changes: 25 additions & 1 deletion modules/abstract-utxo/src/transaction/decode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as utxolib from '@bitgo/utxo-lib';
import { fixedScriptWallet, utxolibCompat } from '@bitgo/wasm-utxo';
import { fixedScriptWallet, hasPsbtMagic, Psbt as WasmPsbt, utxolibCompat } from '@bitgo/wasm-utxo';

import { getNetworkFromCoinName, UtxoCoinName } from '../names';

Expand Down Expand Up @@ -66,6 +66,30 @@ export function decodePsbt(psbt: string | Buffer, coinName: UtxoCoinName): BitGo
return decodePsbtWith(psbt, coinName, 'wasm-utxo');
}

export type PrebuildLike = {
txHex?: string;
txBase64?: string;
txHexPsbt?: string;
};

/**
* Decode a prebuild's PSBT bytes directly into a wasm-utxo descriptor `Psbt`.
*
* Skips the `fixedScriptWallet.BitGoPsbt` intermediate that `decodeTransactionFromPrebuild`
* + `toWasmPsbt` would otherwise round-trip through for descriptor flows.
*/
export function decodeDescriptorPsbt(prebuild: PrebuildLike): WasmPsbt {
const s = prebuild.txHexPsbt ?? prebuild.txHex ?? prebuild.txBase64;
if (!s) {
throw new Error('missing required txHex or txBase64 property');
}
const bytes = stringToBufferTryFormats(s, ['hex', 'base64']);
if (!hasPsbtMagic(bytes)) {
throw new Error('descriptor wallets require PSBT format transactions');
}
return WasmPsbt.deserialize(bytes);
}

export function encodeTransaction(
transaction: utxolib.bitgo.UtxoTransaction<bigint | number> | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt
): Buffer {
Expand Down
9 changes: 2 additions & 7 deletions modules/abstract-utxo/src/transaction/descriptor/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../r
import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from '../outputDifference';
import { UtxoCoinName } from '../../names';
import { sumValues, toWasmPsbt, UtxoLibPsbt } from '../../wasmUtil';
import { decodeDescriptorPsbt } from '../decode';

type ParsedOutput = Omit<descriptorWallet.ParsedOutput, 'script'> & { script: Buffer };

Expand Down Expand Up @@ -128,13 +129,7 @@ export function parse(
if (!recipients) {
throw new Error('recipients is required');
}
const psbt = coin.decodeTransactionFromPrebuild(params.txPrebuild);
let wasmPsbt: Psbt;
try {
wasmPsbt = toWasmPsbt(psbt as Psbt | UtxoLibPsbt | Uint8Array);
} catch (e) {
throw new Error(`expected psbt to be a wasm-utxo or utxo-lib PSBT: ${e instanceof Error ? e.message : e}`);
}
const wasmPsbt = decodeDescriptorPsbt(params.txPrebuild);
const walletKeys = toBip32Triple(keychains);
const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(params.wallet.bitgo.env));
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type { Psbt, descriptorWallet } from '@bitgo/wasm-utxo';
import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin';
import { BaseOutput, BaseParsedTransactionOutputs } from '../types';
import { UtxoCoinName } from '../../names';
import { toWasmPsbt, UtxoLibPsbt } from '../../wasmUtil';
import { UtxoLibPsbt } from '../../wasmUtil';
import { decodeDescriptorPsbt } from '../decode';

import { toBaseParsedTransactionOutputsFromPsbt } from './parse';

Expand Down Expand Up @@ -76,10 +77,9 @@ export async function verifyTransaction<TNumber extends number | bigint>(
params: VerifyTransactionOptions<TNumber>,
descriptorMap: descriptorWallet.DescriptorMap
): Promise<boolean> {
const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
let psbt: Psbt;
try {
psbt = toWasmPsbt(tx as Psbt | UtxoLibPsbt | Uint8Array);
psbt = decodeDescriptorPsbt(params.txPrebuild);
} catch (e) {
const txExplanation = await TxIntentMismatchError.tryGetTxExplanation(
coin as unknown as IBaseCoin,
Expand Down
28 changes: 12 additions & 16 deletions modules/abstract-utxo/src/transaction/explainTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import * as utxolib from '@bitgo/utxo-lib';
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
import { fixedScriptWallet, Psbt as WasmPsbt } from '@bitgo/wasm-utxo';
import { isTriple, IWallet, Triple } from '@bitgo/sdk-core';

import { getDescriptorMapFromWallet, isDescriptorWallet } from '../descriptor';
import { toBip32Triple } from '../keychains';
import { getPolicyForEnv } from '../descriptor/validatePolicy';
import { UtxoCoinName } from '../names';
import type { Unspent } from '../unspent';
import { toWasmPsbt } from '../wasmUtil';

import { getReplayProtectionPubkeys } from './fixedScript/replayProtection';
import type {
Expand All @@ -23,7 +22,7 @@ import * as descriptor from './descriptor';
* change amounts, and transaction outputs.
*/
export function explainTx<TNumber extends number | bigint>(
tx: utxolib.bitgo.UtxoTransaction<TNumber> | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt,
tx: utxolib.bitgo.UtxoTransaction<TNumber> | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt | WasmPsbt,
params: {
wallet?: IWallet;
pubs?: string[];
Expand All @@ -34,20 +33,15 @@ export function explainTx<TNumber extends number | bigint>(
coinName: UtxoCoinName
): TransactionExplanationUtxolibLegacy | TransactionExplanationUtxolibPsbt | TransactionExplanationWasm {
if (params.wallet && isDescriptorWallet(params.wallet)) {
if (tx instanceof utxolib.bitgo.UtxoPsbt) {
if (!params.pubs || !isTriple(params.pubs)) {
throw new Error('pub triple is required for descriptor wallets');
}
const walletKeys = toBip32Triple(params.pubs);
const descriptors = getDescriptorMapFromWallet(
params.wallet,
walletKeys,
getPolicyForEnv(params.wallet.bitgo.env)
);
return descriptor.explainPsbt(toWasmPsbt(tx), descriptors, coinName);
if (!(tx instanceof WasmPsbt)) {
throw new Error('descriptor wallets require PSBT format transactions');
}

throw new Error('legacy transactions are not supported for descriptor wallets');
if (!params.pubs || !isTriple(params.pubs)) {
throw new Error('pub triple is required for descriptor wallets');
}
const walletKeys = toBip32Triple(params.pubs);
const descriptors = getDescriptorMapFromWallet(params.wallet, walletKeys, getPolicyForEnv(params.wallet.bitgo.env));
return descriptor.explainPsbt(tx, descriptors, coinName);
}
if (tx instanceof utxolib.bitgo.UtxoPsbt) {
return fixedScript.explainPsbt(tx, { ...params, customChangePubs: params.customChangeXpubs }, coinName);
Expand All @@ -71,6 +65,8 @@ export function explainTx<TNumber extends number | bigint>(
},
customChangeWalletXpubs: params.customChangeXpubs,
});
} else if (tx instanceof WasmPsbt) {
throw new Error('descriptor Psbt is only supported for descriptor wallets');
} else {
return fixedScript.explainLegacyTx(tx, params, coinName);
}
Expand Down
25 changes: 11 additions & 14 deletions modules/abstract-utxo/src/transaction/signTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import buildDebug from 'debug';
import { AbstractUtxoCoin, SignTransactionOptions } from '../abstractUtxoCoin';
import { getDescriptorMapFromWallet, getPolicyForEnv, isDescriptorWallet } from '../descriptor';
import { fetchKeychains, toBip32Triple } from '../keychains';
import { isUtxoLibPsbt, toWasmPsbt } from '../wasmUtil';
import { isUtxoLibPsbt } from '../wasmUtil';

import * as fixedScript from './fixedScript';
import * as descriptor from './descriptor';
import { decodePsbtWith, encodeTransaction } from './decode';
import { decodeDescriptorPsbt, decodePsbtWith, encodeTransaction } from './decode';

const debug = buildDebug('bitgo:abstract-utxo:transaction:signTransaction');

Expand Down Expand Up @@ -43,14 +43,6 @@ export async function signTransaction<TNumber extends number | bigint>(
throw new Error('missing txPrebuild parameter');
}

let tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);

// When returnLegacyFormat is set, ensure we use wasm-utxo's BitGoPsbt so
// getHalfSignedLegacyFormat() is available after signing.
if (params.returnLegacyFormat && isUtxoLibPsbt(tx)) {
tx = decodePsbtWith(tx.toBuffer(), coin.name, 'wasm-utxo');
}

const signerKeychain = getSignerKeychain(params.prv);

const { wallet } = params;
Expand All @@ -59,17 +51,22 @@ export async function signTransaction<TNumber extends number | bigint>(
if (!signerKeychain) {
throw new Error('missing signer');
}
if (!isUtxoLibPsbt(tx) && !(tx instanceof Uint8Array)) {
throw new Error('descriptor wallets require PSBT format transactions');
}
const psbt = decodeDescriptorPsbt(params.txPrebuild);
const walletKeys = toBip32Triple(await fetchKeychains(coin, wallet));
const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(bitgo.env));
const psbt = toWasmPsbt(tx);
descriptor.signPsbt(psbt, descriptorMap, signerKeychain, {
onUnknownInput: 'throw',
});
return { txHex: Buffer.from(psbt.serialize()).toString('hex') };
} else {
let tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);

// When returnLegacyFormat is set, ensure we use wasm-utxo's BitGoPsbt so
// getHalfSignedLegacyFormat() is available after signing.
if (params.returnLegacyFormat && isUtxoLibPsbt(tx)) {
tx = decodePsbtWith(tx.toBuffer(), coin.name, 'wasm-utxo');
}

const signedTx = await fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), coin.name, {
walletId: params.txPrebuild.walletId,
txInfo: params.txPrebuild.txInfo,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'mocha';
import assert from 'assert';

import { Psbt } from '@bitgo/wasm-utxo';
import * as testutils from '@bitgo/wasm-utxo/testutils';

import type { UtxoWallet } from '../../../../src/wallet';
import { getUtxoCoin } from '../../util/utxoCoins';
import { nockBitGo } from '../../util/nockBitGo';

const { getDescriptorMap, mockPsbtDefaultWithDescriptorTemplate } = testutils.descriptor;
const { getKeyTriple } = testutils;

// End-to-end coverage for descriptor wallet signing through the
// top-level coin.signTransaction entry point (T1-3401). Locks in the
// wasm-utxo decode path that T1-3400 broke.
describe('signTransaction E2E: descriptor wallet (wasm-utxo backend)', function () {
it('produces a signed PSBT with valid user signatures on every input', async function () {
const coin = getUtxoCoin('btc');
// mockPsbtDefaultWithDescriptorTemplate uses getDefaultXPubs('a') —
// i.e. getKeyTriple('a') — so we sign with the same triple.
const keychain = getKeyTriple('a');
const userKey = keychain[0];
const descriptorMap = getDescriptorMap('Wsh2Of3', keychain);

const unsignedPsbt = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3');
const psbtHex = Buffer.from(unsignedPsbt.serialize()).toString('hex');

const keyIds = ['kU', 'kB', 'kG'];
const wallet = {
coinSpecific: () => ({
descriptors: [...descriptorMap.entries()].map(([name, descriptor]) => ({
name,
value: descriptor.toString(),
})),
}),
keyIds: () => keyIds,
} as unknown as UtxoWallet;

// Mock the keychain fetch — fetchKeychains pulls each key by id.
keyIds.forEach((id, i) => {
nockBitGo().get(`/api/v2/${coin.getChain()}/key/${id}`).reply(200, { pub: keychain[i].neutered().toBase58() });
});

// decodeWith: 'wasm-utxo' is explicit to lock in the BitGoPsbt
// decode path that T1-3400 broke; this is also the production
// default after 1702a08009.
const result = await coin.signTransaction({
txPrebuild: { txHex: psbtHex, decodeWith: 'wasm-utxo' },
prv: userKey.toBase58(),
wallet,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);

assert.ok('txHex' in result, 'expected signTransaction to return { txHex }');
const signedPsbt = Psbt.deserialize(Buffer.from(result.txHex, 'hex'));

const inputs = signedPsbt.getInputs();
assert.ok(inputs.length > 0, 'expected at least one input');
inputs.forEach((_input, vin) => {
assert.ok(signedPsbt.hasPartialSignatures(vin), `input ${vin} has no partial signatures`);
const sigs = signedPsbt.getPartialSignatures(vin);
assert.ok(sigs.length > 0, `input ${vin} returned empty partial signatures`);
// Pubkeys in partial sigs are the descriptor-derived child keys, not
// the user master pubkey; assert that each sig validates at its claimed
// pubkey, which is the strongest "signing actually worked" check.
for (const sig of sigs) {
assert.ok(
signedPsbt.validateSignatureAtInput(vin, sig.pubkey),
`input ${vin} has an invalid signature for pubkey ${Buffer.from(sig.pubkey).toString('hex')}`
);
}
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'mocha';
import assert from 'assert';

import * as testutils from '@bitgo/wasm-utxo/testutils';

import { signTransaction } from '../../../../src/transaction/signTransaction';
import type { UtxoWallet } from '../../../../src/wallet';
import { defaultBitGo, getUtxoCoin } from '../../util/utxoCoins';
import { getDefaultWalletKeys } from '../../util/keychains';

const { getDescriptorMap, mockPsbtDefaultWithDescriptorTemplate } = testutils.descriptor;

// Regression test for T1-3400. The descriptor sign guard previously
// only accepted utxo-lib PSBTs (UtxoLibPsbt) and Uint8Array, throwing
// `descriptor wallets require PSBT format transactions` when the
// decoded prebuild was a wasm-utxo BitGoPsbt — the default after
// defaultSdkBackend flipped to 'wasm-utxo' for all coins.
describe('signTransaction: descriptor wallet with BitGoPsbt prebuild (T1-3400)', function () {
it('does not reject BitGoPsbt with the descriptor PSBT guard', async function () {
const coin = getUtxoCoin('btc');
const descriptorMap = getDescriptorMap('Wsh2Of3');
const psbt = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3');
const psbtHex = Buffer.from(psbt.serialize()).toString('hex');

const wallet = {
coinSpecific: () => ({
descriptors: [...descriptorMap.entries()].map(([name, descriptor]) => ({
name,
value: descriptor.toString(),
})),
}),
keyIds: () => ['k0', 'k1', 'k2'],
} as unknown as UtxoWallet;

const userPrv = getDefaultWalletKeys().user.toBase58();

let caught: Error | undefined;
try {
// decodeWith: 'wasm-utxo' forces decodeTransactionFromPrebuild to
// return fixedScriptWallet.BitGoPsbt (the path that used to trip
// the guard).
await signTransaction(coin, defaultBitGo, {
txPrebuild: { txHex: psbtHex, decodeWith: 'wasm-utxo' },
prv: userPrv,
wallet,
} as any);
} catch (e) {
caught = e as Error;
}

assert.ok(
!caught || !/descriptor wallets require PSBT format transactions/.test(caught.message),
`descriptor sign guard incorrectly rejected BitGoPsbt: ${caught?.message}`
);
});
});
Loading