Skip to content

Commit 79399e4

Browse files
OttoAllmendingerllm-git
andcommitted
test(abstract-utxo): add integration tests for custom change wallet key fetching
Add tests for parseTransaction to verify: - Custom change wallet keys are fetched and signatures verified - Transactions without customChangeWalletId skip custom change - Invalid signatures are rejected Co-authored-by: llm-git <llm-git@ttll.de>
1 parent c61c543 commit 79399e4

2 files changed

Lines changed: 138 additions & 13 deletions

File tree

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)