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
36 changes: 31 additions & 5 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import {
v1Sweep,
V1SweepParams,
} from './recovery';
import { isReplayProtectionUnspent } from './transaction/fixedScript/replayProtection';
import { getReplayProtectionPubkeys, isReplayProtectionUnspent } from './transaction/fixedScript/replayProtection';
import { supportedCrossChainRecoveries } from './config';
import {
assertValidTransactionRecipient,
Expand All @@ -81,6 +81,7 @@ import {
getMainnetCoinName,
getNetworkFromCoinName,
isTestnetCoin,
isUtxoCoinNameMainnet,
UtxoCoinName,
UtxoCoinNameMainnet,
} from './names';
Expand Down Expand Up @@ -143,6 +144,28 @@ type UtxoCustomSigningFunction<TNumber extends number | bigint> = {

const { isChainCode, scriptTypeForChain, outputScripts } = bitgo;

/**
* Check if a decoded transaction has at least one taproot key path spend (MuSig2) input.
* Works for both utxolib UtxoPsbt and wasm-utxo BitGoPsbt.
*/
function hasKeyPathSpendInput<TNumber extends number | bigint>(
tx: DecodedTransaction<TNumber>,
pubs: string[] | undefined,
coinName: UtxoCoinName
): boolean {
if (tx instanceof bitgo.UtxoPsbt) {
return bitgo.isTransactionWithKeyPathSpendInput(tx);
}
if (tx instanceof fixedScriptWallet.BitGoPsbt) {
assert(pubs && isTriple(pubs), 'pub triple is required to check for key path spend inputs in wasm-utxo PSBT');
const rootWalletKeys = fixedScriptWallet.RootWalletKeys.fromXpubs(pubs);
const replayProtection = { publicKeys: getReplayProtectionPubkeys(coinName) };
const parsed = tx.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection);
return parsed.inputs.some((input) => input.scriptType === 'p2trMusig2KeyPath');
}
return false;
}

/**
* Convert ValidationError to TxIntentMismatchRecipientError with structured data
*
Expand Down Expand Up @@ -379,14 +402,17 @@ export abstract class AbstractUtxoCoin

public altScriptHash?: number;
public supportAltScriptDestination?: boolean;
public defaultSdkBackend: SdkBackend = 'utxolib';
public readonly amountType: 'number' | 'bigint';

protected constructor(bitgo: BitGoBase, amountType: 'number' | 'bigint' = 'number') {
super(bitgo);
this.amountType = amountType;
}

get defaultSdkBackend(): SdkBackend {
return isUtxoCoinNameMainnet(this.name) ? 'utxolib' : 'wasm-utxo';
}

/**
* @deprecated - will be removed when we drop support for utxolib
* Use `name` property instead.
Expand Down Expand Up @@ -801,20 +827,20 @@ export abstract class AbstractUtxoCoin
/**
* Sign a transaction with a custom signing function. Example use case is express external signer
* @param customSigningFunction custom signing function that returns a single signed transaction
* @param signTransactionParams parameters for custom signing function. Includes txPrebuild and pubs (for legacy tx only).
* @param signTransactionParams parameters for custom signing function. Includes txPrebuild and pubs
*
* @returns signed transaction as hex string
*/
async signWithCustomSigningFunction<TNumber extends number | bigint>(
customSigningFunction: UtxoCustomSigningFunction<TNumber>,
signTransactionParams: { txPrebuild: TransactionPrebuild<TNumber>; pubs?: string[] }
signTransactionParams: { txPrebuild: TransactionPrebuild<TNumber>; pubs: string[] }
): Promise<SignedTransaction> {
const txHex = signTransactionParams.txPrebuild.txHex;
assert(txHex, 'missing txHex parameter');

const tx = this.decodeTransaction(txHex);

const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx);
const isTxWithKeyPathSpendInput = hasKeyPathSpendInput(tx, signTransactionParams.pubs, this.name);

if (!isTxWithKeyPathSpendInput) {
return await customSigningFunction({ ...signTransactionParams, coin: this });
Expand Down
7 changes: 4 additions & 3 deletions modules/abstract-utxo/test/unit/customSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,14 @@ describe('UTXO Custom Signer Function', function () {
});

function nocks(txPrebuild: { txHex: string }) {
const pubs = rootWalletKey.triple.map((k) => k.neutered().toBase58());
nock(bgUrl)
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/build`)
.reply(200, { ...txPrebuild, txInfo: {} });
nock(bgUrl).get(`/api/v2/${wallet.coin()}/public/block/latest`).reply(200, { height: 1000 });
nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[0]}`).reply(200, { pub: 'pub' });
nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[1]}`).reply(200, { pub: 'pub' });
nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[2]}`).reply(200, { pub: 'pub' });
nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[0]}`).reply(200, { pub: pubs[0] });
nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[1]}`).reply(200, { pub: pubs[1] });
nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[2]}`).reply(200, { pub: pubs[2] });
return nock(bgUrl).post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`).reply(200, { ok: true });
}

Expand Down
36 changes: 32 additions & 4 deletions modules/abstract-utxo/test/unit/explainTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
import assert from 'assert';

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

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

nock.disableNetConnect();

const bgUrl = common.Environments[defaultBitGo.getEnv()].uri;

describe('Explain Transaction', function () {
afterEach(function () {
nock.cleanAll();
});

describe('Verify paygo output when explaining psbt transaction', function () {
const coin = getUtxoCoin('tbtc4');

const pubs: Triple<string> = [
'xpub661MyMwAqRbcFaKvNBFdV6HY7ibXxFSbL7rDjY1cVM8s3pGPTNKfjTu8SmatNZ7AcZQehSqcEnC7vezMoprQvhqQUszLhuY4G8ruv6PGEr7',
'xpub661MyMwAqRbcGkAVVQVHrEYQA4hfbDW9Rpn35b6sXA9TSBd5Qzjwz7F6Weje57kBVeVfimfJjXutwUDBSMz5yRwsWik9gNyxrdvSaJbjgi6',
'xpub661MyMwAqRbcGCCL3GYNbvKs1t5k5yeKZcV5smto9T5Z17zkcgRF4X9uzDfPxMHHedwF4JcJ6kpg8M2NWHEFC5LMSv1t3nMMm1GC9PcVmq5',
];

const keyIds = ['key-user', 'key-backup', 'key-bitgo'];

function nockKeyFetch(): void {
keyIds.forEach((id, i) => {
nock(bgUrl).get(`/api/v2/${coin.getChain()}/key/${id}`).reply(200, { pub: pubs[i] });
});
}

it('should detect and verify paygo address proof in PSBT', async function () {
// Call explainTransaction
await coin.explainTransaction(psbtTxHex);
nockKeyFetch();
const wallet = new Wallet(defaultBitGo, coin, {
id: 'mock-wallet-id',
coin: coin.getChain(),
keys: keyIds,
});
await coin.explainTransaction(psbtTxHex, wallet);
});
});

Expand Down
9 changes: 3 additions & 6 deletions modules/abstract-utxo/test/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ describe('manage unspents', function () {
);

nocks.push(
...psbts.map((psbt) =>
...psbts.map(() =>
nock(bgUrl)
.post(
`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`,
_.matches({ txHex: psbt.signAllInputsHD(rootWalletKey.user).toHex() })
_.matches({ type: 'consolidate', bulk: true })
)
.reply(200)
)
Expand Down Expand Up @@ -92,10 +92,7 @@ describe('manage unspents', function () {

nocks.push(
nock(bgUrl)
.post(
`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`,
_.matches({ txHex: psbt.signAllInputsHD(rootWalletKey.user).toHex() })
)
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`, _.matches({ type: 'consolidate' }))
.reply(200)
);

Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ export interface IBaseCoin {
presignTransaction(params: PresignTransactionOptions): Promise<PresignTransactionOptions>;
signWithCustomSigningFunction?(
customSigningFunction: CustomSigningFunction,
signTransactionParams: { txPrebuild: TransactionPrebuild; pubs?: string[] }
signTransactionParams: { txPrebuild: TransactionPrebuild; pubs: string[] }
): Promise<SignedTransaction>;
newWalletObject(walletParams: any): IWallet;
feeEstimate(params: FeeEstimateOptions): Promise<any>;
Expand Down
6 changes: 5 additions & 1 deletion modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2194,7 +2194,11 @@ export class Wallet implements IWallet {

if (_.isFunction(params.customSigningFunction)) {
if (typeof this.baseCoin.signWithCustomSigningFunction === 'function') {
return this.baseCoin.signWithCustomSigningFunction(params.customSigningFunction, signTransactionParams);
assert(pubs, 'pubs are required for custom signing');
return this.baseCoin.signWithCustomSigningFunction(params.customSigningFunction, {
...signTransactionParams,
pubs,
});
}
const keys = await this.baseCoin.keychains().getKeysForSigning({ wallet: this });
const signTransactionParamsWithSeed = {
Expand Down