Skip to content

Commit afce442

Browse files
OttoAllmendingerllm-git
andcommitted
test(abstract-utxo): add integration tests for custom change wallet
Add parseTransaction tests to verify custom change wallet key fetching, signature verification, and proper handling of transactions without customChangeWalletId. Tests cover both valid and invalid signatures. BTC-2650 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 146b2e9 commit afce442

File tree

2 files changed

+138
-13
lines changed

2 files changed

+138
-13
lines changed

modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ export async function parseTransaction<TNumber extends bigint | number>(
209209
const explanation: TransactionExplanation = await coin.explainTransaction<TNumber>({
210210
txHex: txPrebuild.txHex,
211211
txInfo: txPrebuild.txInfo,
212+
decodeWith: txPrebuild.decodeWith,
212213
pubs: keychainArray.map((k) => k.pub) as Triple<string>,
213214
customChangeXpubs,
214215
});

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

Lines changed: 137 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import assert from 'node:assert/strict';
22

3+
import nock = require('nock');
34
import { CoinName, fixedScriptWallet, BIP32, message } from '@bitgo/wasm-utxo';
45
import * as utxolib from '@bitgo/utxo-lib';
56
import { testutil } from '@bitgo/utxo-lib';
7+
import { common, Wallet } from '@bitgo/sdk-core';
8+
import { getSeed } from '@bitgo/sdk-test';
69

710
import { explainPsbt as explainPsbtUtxolib, explainPsbtWasm } from '../../src/transaction/fixedScript';
811
import { verifyKeySignature } from '../../src/verifyKey';
912
import { SdkBackend } from '../../src/transaction';
1013

14+
import { defaultBitGo, getUtxoCoin } from './util';
15+
1116
function explainPsbt(
1217
psbt: utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt,
1318
walletKeys: utxolib.bitgo.RootWalletKeys,
@@ -28,9 +33,25 @@ function explainPsbt(
2833

2934
function describeWithBackend(sdkBackend: SdkBackend) {
3035
describe(`Custom Change Wallets (sdkBackend=${sdkBackend})`, function () {
36+
const coin = getUtxoCoin('btc');
3137
const network = utxolib.networks.bitcoin;
38+
const bgUrl = common.Environments[defaultBitGo.getEnv()].uri;
3239
const rootWalletKeys = testutil.getDefaultWalletKeys();
3340
const customChangeWalletKeys = testutil.getWalletKeysForSeed('custom change');
41+
const userPrivateKey = BIP32.fromBase58(rootWalletKeys.triple[0].toBase58()).privateKey!;
42+
43+
const mainKeyIds = rootWalletKeys.triple.map((k) => getSeed(k.neutered().toBase58()).toString('hex'));
44+
const customChangeKeyIds = customChangeWalletKeys.triple.map((k) =>
45+
getSeed(k.neutered().toBase58()).toString('hex')
46+
);
47+
const customChangeKeySignatures = Object.fromEntries(
48+
(['user', 'backup', 'bitgo'] as const).map((name, i) => [
49+
name,
50+
Buffer.from(
51+
message.signMessage(customChangeWalletKeys.triple[i].neutered().toBase58(), userPrivateKey)
52+
).toString('hex'),
53+
])
54+
) as Record<'user' | 'backup' | 'bitgo', string>;
3455

3556
const inputs: testutil.Input[] = [{ scriptType: 'p2sh', value: BigInt(10000) }];
3657
const outputs: testutil.Output[] = [
@@ -42,21 +63,34 @@ function describeWithBackend(sdkBackend: SdkBackend) {
4263
{ scriptType: 'p2sh', value: BigInt(3000), walletKeys: null },
4364
];
4465

45-
let psbt: utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt = testutil.constructPsbt(
46-
inputs,
47-
outputs,
48-
network,
49-
rootWalletKeys,
50-
'unsigned',
51-
{
52-
addGlobalXPubs: true,
53-
}
54-
);
66+
const utxolibPsbt = testutil.constructPsbt(inputs, outputs, network, rootWalletKeys, 'unsigned', {
67+
addGlobalXPubs: true,
68+
});
69+
const psbt: utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt =
70+
sdkBackend === 'wasm-utxo' ? fixedScriptWallet.BitGoPsbt.fromBytes(utxolibPsbt.toBuffer(), 'btc') : utxolibPsbt;
71+
72+
const externalAddress = utxolib.address.fromOutputScript(utxolibPsbt.txOutputs[2].script, network);
73+
const customChangeWalletId = 'custom-change-wallet-id';
74+
const mainWalletId = 'main-wallet-id';
75+
76+
function nockKeyFetch(keyIds: string[], keys: utxolib.bitgo.RootWalletKeys): nock.Scope[] {
77+
return keyIds.map((id, i) =>
78+
nock(bgUrl)
79+
.get(`/api/v2/${coin.getChain()}/key/${id}`)
80+
.reply(200, { pub: keys.triple[i].neutered().toBase58() })
81+
);
82+
}
5583

56-
if (sdkBackend === 'wasm-utxo') {
57-
psbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbt.toBuffer(), 'btc');
84+
function nockCustomChangeWallet(): nock.Scope {
85+
return nock(bgUrl).get(`/api/v2/${coin.getChain()}/wallet/${customChangeWalletId}`).reply(200, {
86+
id: customChangeWalletId,
87+
keys: customChangeKeyIds,
88+
coin: coin.getChain(),
89+
});
5890
}
5991

92+
afterEach(() => nock.cleanAll());
93+
6094
it('classifies custom change output when customChangePubs is provided', function () {
6195
const explanation = explainPsbt(psbt, rootWalletKeys, customChangeWalletKeys, 'btc');
6296

@@ -85,7 +119,6 @@ function describeWithBackend(sdkBackend: SdkBackend) {
85119
});
86120

87121
it('verifies valid custom change key signatures', function () {
88-
const userPrivateKey = BIP32.fromBase58(rootWalletKeys.triple[0].toBase58()).privateKey!;
89122
const userPub = rootWalletKeys.triple[0].neutered().toBase58();
90123

91124
for (const key of customChangeWalletKeys.triple) {
@@ -110,6 +143,97 @@ function describeWithBackend(sdkBackend: SdkBackend) {
110143
);
111144
}
112145
});
146+
147+
describe('parseTransaction', function () {
148+
it('fetches custom change wallet keys and verifies signatures', async function () {
149+
const wallet = new Wallet(defaultBitGo, coin, {
150+
id: mainWalletId,
151+
keys: mainKeyIds,
152+
coin: coin.getChain(),
153+
coinSpecific: { customChangeWalletId },
154+
customChangeKeySignatures,
155+
});
156+
157+
const nocks = [
158+
...nockKeyFetch(mainKeyIds, rootWalletKeys),
159+
nockCustomChangeWallet(),
160+
...nockKeyFetch(customChangeKeyIds, customChangeWalletKeys),
161+
];
162+
163+
const parsed = await coin.parseTransaction({
164+
txParams: { recipients: [{ address: externalAddress, amount: '3000' }] },
165+
txPrebuild: { txHex: utxolibPsbt.toHex(), decodeWith: sdkBackend },
166+
wallet: wallet as unknown as import('../../src').UtxoWallet,
167+
});
168+
169+
for (const n of nocks) assert.ok(n.isDone());
170+
171+
assert.ok(parsed.customChange);
172+
assert.strictEqual(parsed.customChange.keys.length, 3);
173+
for (let i = 0; i < 3; i++) {
174+
assert.strictEqual(parsed.customChange.keys[i].pub, customChangeWalletKeys.triple[i].neutered().toBase58());
175+
}
176+
177+
assert.strictEqual(parsed.explicitExternalOutputs.length, 1);
178+
assert.strictEqual(parsed.explicitExternalOutputs[0].amount, '3000');
179+
});
180+
181+
it('has no custom change when wallet lacks customChangeWalletId', async function () {
182+
const wallet = new Wallet(defaultBitGo, coin, {
183+
id: mainWalletId,
184+
keys: mainKeyIds,
185+
coin: coin.getChain(),
186+
coinSpecific: {},
187+
});
188+
189+
const nocks = nockKeyFetch(mainKeyIds, rootWalletKeys);
190+
191+
const parsed = await coin.parseTransaction({
192+
txParams: { recipients: [{ address: externalAddress, amount: '3000' }] },
193+
txPrebuild: { txHex: utxolibPsbt.toHex(), decodeWith: sdkBackend },
194+
wallet: wallet as unknown as import('../../src').UtxoWallet,
195+
});
196+
197+
for (const n of nocks) assert.ok(n.isDone());
198+
199+
assert.strictEqual(parsed.customChange, undefined);
200+
assert.strictEqual(parsed.needsCustomChangeKeySignatureVerification, false);
201+
});
202+
203+
it('rejects invalid custom change key signatures', async function () {
204+
const wrongKey = BIP32.fromBase58(testutil.getWalletKeysForSeed('wrong').triple[0].toBase58());
205+
const badSignatures = Object.fromEntries(
206+
(['user', 'backup', 'bitgo'] as const).map((name, i) => [
207+
name,
208+
Buffer.from(
209+
message.signMessage(customChangeWalletKeys.triple[i].neutered().toBase58(), wrongKey.privateKey!)
210+
).toString('hex'),
211+
])
212+
) as Record<'user' | 'backup' | 'bitgo', string>;
213+
214+
const wallet = new Wallet(defaultBitGo, coin, {
215+
id: mainWalletId,
216+
keys: mainKeyIds,
217+
coin: coin.getChain(),
218+
coinSpecific: { customChangeWalletId },
219+
customChangeKeySignatures: badSignatures,
220+
});
221+
222+
nockKeyFetch(mainKeyIds, rootWalletKeys);
223+
nockCustomChangeWallet();
224+
nockKeyFetch(customChangeKeyIds, customChangeWalletKeys);
225+
226+
await assert.rejects(
227+
() =>
228+
coin.parseTransaction({
229+
txParams: { recipients: [{ address: externalAddress, amount: '3000' }] },
230+
txPrebuild: { txHex: utxolibPsbt.toHex(), decodeWith: sdkBackend },
231+
wallet: wallet as unknown as import('../../src').UtxoWallet,
232+
}),
233+
/failed to verify custom change .* key signature/
234+
);
235+
});
236+
});
113237
});
114238
}
115239

0 commit comments

Comments
 (0)