Skip to content

Commit 352beb8

Browse files
OttoAllmendingerllm-git
andcommitted
feat(abstract-utxo): add signTransaction tests
Add comprehensive tests for signTransaction functionality, covering: - PSBT with taprootKeyPathSpend inputs - PSBT without taprootKeyPathSpend inputs - Network transactions - Error handling for cache misses and unsupported locking scripts - Validation of signature and nonce counts Issue: BTC-2866 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 1ada5da commit 352beb8

2 files changed

Lines changed: 241 additions & 231 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import 'mocha';
2+
import * as assert from 'assert';
3+
4+
import * as utxolib from '@bitgo/utxo-lib';
5+
import nock = require('nock');
6+
import { testutil } from '@bitgo/utxo-lib';
7+
import { common, Triple } from '@bitgo/sdk-core';
8+
9+
import { getReplayProtectionPubkeys } from '../../src';
10+
import type { Unspent } from '../../src/unspent';
11+
12+
import { getUtxoWallet, getDefaultWalletKeys, getUtxoCoin, keychainsBase58, defaultBitGo } from './util';
13+
14+
describe('signTransaction', function () {
15+
const bgUrl = common.Environments[defaultBitGo.getEnv()].uri;
16+
17+
const coin = getUtxoCoin('btc');
18+
const wallet = getUtxoWallet(coin, { id: '5b34252f1bf349930e34020a00000000', coin: coin.getChain() });
19+
const rootWalletKeys = getDefaultWalletKeys();
20+
const userPrv = rootWalletKeys.user.toBase58();
21+
const pubs = keychainsBase58.map((v) => v.pub) as Triple<string>;
22+
23+
function validatePsbt(txHex: string, targetSigCount: 0 | 1, targetNonceCount?: 1 | 2) {
24+
const psbt = utxolib.bitgo.createPsbtFromHex(txHex, coin.network);
25+
psbt.data.inputs.forEach((input, index) => {
26+
const parsed = utxolib.bitgo.parsePsbtInput(input);
27+
if (parsed.scriptType === 'taprootKeyPathSpend') {
28+
assert.ok(targetNonceCount);
29+
const nonce = psbt.getProprietaryKeyVals(index, {
30+
identifier: utxolib.bitgo.PSBT_PROPRIETARY_IDENTIFIER,
31+
subtype: utxolib.bitgo.ProprietaryKeySubtype.MUSIG2_PUB_NONCE,
32+
});
33+
assert.strictEqual(nonce.length, targetNonceCount);
34+
}
35+
const expectedSigCount = parsed.scriptType === 'p2shP2pk' || targetSigCount === 0 ? undefined : 1;
36+
assert.strictEqual(parsed.signatures?.length, expectedSigCount);
37+
});
38+
}
39+
40+
function validateTx(txHex: string, unspents: Unspent<bigint>[], targetSigCount: 0 | 1) {
41+
const tx = utxolib.bitgo.createTransactionFromHex(txHex, coin.network);
42+
unspents.forEach((u, i) => {
43+
const sigCount = utxolib.bitgo.getStrictSignatureCount(tx.ins[i]);
44+
const expectedSigCount = utxolib.bitgo.isWalletUnspent(u) && !!targetSigCount ? 1 : 0;
45+
assert.strictEqual(sigCount, expectedSigCount);
46+
});
47+
}
48+
49+
async function signTransaction(
50+
tx: utxolib.bitgo.UtxoPsbt | utxolib.bitgo.UtxoTransaction<bigint>,
51+
useSigningSteps: boolean,
52+
unspents?: Unspent<bigint>[]
53+
) {
54+
const isPsbt = tx instanceof utxolib.bitgo.UtxoPsbt;
55+
const isTxWithTaprootKeyPathSpend = isPsbt && utxolib.bitgo.isTransactionWithKeyPathSpendInput(tx);
56+
const txHex = tx.toHex();
57+
58+
function nockSignPsbt(psbtHex: string): nock.Scope {
59+
const psbt = utxolib.bitgo.createPsbtFromHex(psbtHex, coin.network);
60+
return nock(bgUrl)
61+
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/signpsbt`, (body) => body.psbt)
62+
.reply(200, { psbt: psbt.setAllInputsMusig2NonceHD(rootWalletKeys.bitgo).toHex() });
63+
}
64+
65+
if (!useSigningSteps) {
66+
let scope: nock.Scope | undefined;
67+
if (tx instanceof utxolib.bitgo.UtxoPsbt && isTxWithTaprootKeyPathSpend) {
68+
scope = nockSignPsbt(tx.clone().setAllInputsMusig2NonceHD(rootWalletKeys.bitgo).toHex());
69+
}
70+
const psbt = await coin.signTransaction({
71+
txPrebuild: {
72+
txHex,
73+
txInfo: isPsbt ? undefined : { unspents },
74+
walletId: isTxWithTaprootKeyPathSpend ? wallet.id() : undefined,
75+
},
76+
prv: userPrv,
77+
pubs: isPsbt ? undefined : pubs,
78+
});
79+
assert.ok('txHex' in psbt);
80+
if (isPsbt) {
81+
validatePsbt(psbt.txHex, 1, 2);
82+
} else {
83+
assert.ok(unspents);
84+
validateTx(psbt.txHex, unspents, 1);
85+
}
86+
if (scope) {
87+
assert.strictEqual(scope.isDone(), true);
88+
}
89+
return;
90+
}
91+
92+
const signerNoncePsbt = await coin.signTransaction({
93+
txPrebuild: { txHex },
94+
prv: userPrv,
95+
signingStep: 'signerNonce',
96+
});
97+
assert.ok('txHex' in signerNoncePsbt);
98+
if (isPsbt) {
99+
validatePsbt(signerNoncePsbt.txHex, 0, isTxWithTaprootKeyPathSpend ? 1 : undefined);
100+
} else {
101+
assert.ok(unspents);
102+
validateTx(signerNoncePsbt.txHex, unspents, 0);
103+
}
104+
105+
let scope: nock.Scope | undefined;
106+
if (isTxWithTaprootKeyPathSpend) {
107+
scope = nockSignPsbt(signerNoncePsbt.txHex);
108+
}
109+
110+
const cosignerNoncePsbt = await coin.signTransaction({
111+
txPrebuild: { ...signerNoncePsbt, walletId: wallet.id() },
112+
signingStep: 'cosignerNonce',
113+
});
114+
assert.ok('txHex' in cosignerNoncePsbt);
115+
if (isPsbt) {
116+
validatePsbt(cosignerNoncePsbt.txHex, 0, isTxWithTaprootKeyPathSpend ? 2 : undefined);
117+
} else {
118+
assert.ok(unspents);
119+
validateTx(cosignerNoncePsbt.txHex, unspents, 0);
120+
}
121+
122+
if (scope) {
123+
assert.strictEqual(scope.isDone(), true);
124+
}
125+
126+
const signerSigPsbt = await coin.signTransaction({
127+
txPrebuild: { ...cosignerNoncePsbt, txInfo: isPsbt ? undefined : { unspents } },
128+
prv: userPrv,
129+
pubs: isPsbt ? undefined : pubs,
130+
signingStep: 'signerSignature',
131+
});
132+
assert.ok('txHex' in signerSigPsbt);
133+
if (isPsbt) {
134+
validatePsbt(signerSigPsbt.txHex, 1, isTxWithTaprootKeyPathSpend ? 2 : undefined);
135+
} else {
136+
assert.ok(unspents);
137+
validateTx(signerSigPsbt.txHex, unspents, 1);
138+
}
139+
}
140+
141+
it('customSigningFunction flow - PSBT with taprootKeyPathSpend inputs', async function () {
142+
const inputs: testutil.Input[] = testutil.inputScriptTypes.map((scriptType) => ({
143+
scriptType,
144+
value: BigInt(1000),
145+
}));
146+
const unspentSum = inputs.reduce((prev: bigint, curr) => prev + curr.value, BigInt(0));
147+
const outputs: testutil.Output[] = [{ scriptType: 'p2sh', value: unspentSum - BigInt(1000) }];
148+
const psbt = testutil.constructPsbt(inputs, outputs, coin.network, rootWalletKeys, 'unsigned', {
149+
p2shP2pkKey: getReplayProtectionPubkeys(coin.name)[0],
150+
});
151+
152+
for (const v of [false, true]) {
153+
await signTransaction(psbt, v);
154+
}
155+
});
156+
157+
it('customSigningFunction flow - PSBT without taprootKeyPathSpend inputs', async function () {
158+
const inputs: testutil.Input[] = testutil.inputScriptTypes
159+
.filter((v) => v !== 'taprootKeyPathSpend')
160+
.map((scriptType) => ({
161+
scriptType,
162+
value: BigInt(1000),
163+
}));
164+
const unspentSum = inputs.reduce((prev: bigint, cur) => prev + cur.value, BigInt(0));
165+
const outputs: testutil.Output[] = [{ scriptType: 'p2sh', value: unspentSum - BigInt(1000) }];
166+
const psbt = testutil.constructPsbt(inputs, outputs, coin.network, rootWalletKeys, 'unsigned');
167+
168+
for (const v of [false, true]) {
169+
await signTransaction(psbt, v);
170+
}
171+
});
172+
173+
it('customSigningFunction flow - Network Tx', async function () {
174+
const inputs: testutil.TxnInput<bigint>[] = testutil.txnInputScriptTypes
175+
.filter((v) => v !== 'p2shP2pk')
176+
.map((scriptType) => ({
177+
scriptType,
178+
value: BigInt(1000),
179+
}));
180+
const unspentSum = inputs.reduce((prev: bigint, curr) => prev + curr.value, BigInt(0));
181+
const outputs: testutil.TxnOutput<bigint>[] = [{ scriptType: 'p2sh', value: unspentSum - BigInt(1000) }];
182+
const txBuilder = testutil.constructTxnBuilder(inputs, outputs, coin.network, rootWalletKeys, 'unsigned');
183+
const unspents = inputs.map((v, i) => testutil.toTxnUnspent(v, i, coin.network, rootWalletKeys));
184+
185+
for (const v of [false, true]) {
186+
await signTransaction(txBuilder.buildIncomplete(), v, unspents);
187+
}
188+
});
189+
190+
it('fails on PSBT cache miss', async function () {
191+
const inputs: testutil.Input[] = [{ scriptType: 'taprootKeyPathSpend', value: BigInt(1000) }];
192+
const unspentSum = inputs.reduce((prev: bigint, curr) => prev + curr.value, BigInt(0));
193+
const outputs: testutil.Output[] = [{ scriptType: 'p2sh', value: unspentSum - BigInt(1000) }];
194+
const psbt = testutil.constructPsbt(inputs, outputs, coin.network, rootWalletKeys, 'unsigned');
195+
196+
await assert.rejects(
197+
async () => {
198+
await coin.signTransaction({
199+
txPrebuild: { txHex: psbt.toHex() },
200+
prv: userPrv,
201+
signingStep: 'signerSignature',
202+
});
203+
},
204+
{
205+
message: `Psbt is missing from txCache (cache size 0).
206+
This may be due to the request being routed to a different BitGo-Express instance that for signing step 'signerNonce'.`,
207+
}
208+
);
209+
});
210+
211+
it('fails on unsupported locking script', async function () {
212+
const inputs: testutil.Input[] = [
213+
{ scriptType: 'p2wsh', value: BigInt(1000) },
214+
{ scriptType: 'p2trMusig2', value: BigInt(1000) },
215+
];
216+
const unspentSum = inputs.reduce((prev: bigint, curr) => prev + curr.value, BigInt(0));
217+
const outputs: testutil.Output[] = [{ scriptType: 'p2sh', value: unspentSum - BigInt(500) }];
218+
const psbt = testutil.constructPsbt(inputs, outputs, coin.network, rootWalletKeys, 'unsigned');
219+
220+
// override the 1st PSBT input with unsupported 2 of 2 multi-sig locking script.
221+
const unspent = testutil.toUnspent(inputs[0], 0, coin.network, rootWalletKeys);
222+
if (!utxolib.bitgo.isWalletUnspent(unspent)) {
223+
throw new Error('invalid unspent');
224+
}
225+
const { publicKeys } = rootWalletKeys.deriveForChainAndIndex(unspent.chain, unspent.index);
226+
const script2Of2 = utxolib.payments.p2ms({ m: 2, pubkeys: [publicKeys[0], publicKeys[1]] });
227+
psbt.data.inputs[0].witnessScript = script2Of2.output;
228+
229+
await assert.rejects(
230+
async () => {
231+
await coin.signTransaction({
232+
txPrebuild: { txHex: psbt.toHex() },
233+
prv: userPrv,
234+
});
235+
},
236+
{
237+
message: `length mismatch`,
238+
}
239+
);
240+
});
241+
});

0 commit comments

Comments
 (0)