Skip to content

Commit 146b2e9

Browse files
OttoAllmendingerllm-git
andcommitted
refactor(abstract-utxo): simplify custom change wallet tests
Replace SDK-dependent test with pure utxo-lib implementation using PSBT construction and explanation. Add signature verification tests using wasm-utxo message signing. Support both utxolib and wasm-utxo backends. BTC-2650 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent feb2432 commit 146b2e9

2 files changed

Lines changed: 111 additions & 192 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ function toExternalOutput(output: ParsedExternalOutput): Output {
4040

4141
export function explainPsbtWasm(
4242
psbt: fixedScriptWallet.BitGoPsbt,
43-
walletXpubs: Triple<string>,
43+
walletXpubs: Triple<string> | fixedScriptWallet.RootWalletKeys,
4444
params: {
4545
replayProtection: {
4646
checkSignature?: boolean;
4747
publicKeys: Buffer[];
4848
};
49-
customChangeWalletXpubs?: Triple<string>;
49+
customChangeWalletXpubs?: Triple<string> | fixedScriptWallet.RootWalletKeys;
5050
}
5151
): TransactionExplanationWasm {
5252
const parsed = psbt.parseTransactionWithWalletKeys(walletXpubs, { replayProtection: params.replayProtection });
Lines changed: 109 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -1,198 +1,117 @@
1-
import assert from 'assert';
2-
3-
import should = require('should');
4-
import * as sinon from 'sinon';
5-
import { Wallet } from '@bitgo/sdk-core';
6-
import { BIP32, message } from '@bitgo/wasm-utxo';
7-
8-
import { generateAddress } from '../../src';
9-
10-
import { getUtxoCoin } from './util';
11-
12-
describe('Custom Change Wallets', () => {
13-
const coin = getUtxoCoin('tbtc');
14-
15-
const keys = {
16-
send: {
17-
user: { id: '0', key: coin.keychains().create() },
18-
backup: { id: '1', key: coin.keychains().create() },
19-
bitgo: { id: '2', key: coin.keychains().create() },
20-
},
21-
change: {
22-
user: { id: '3', key: coin.keychains().create() },
23-
backup: { id: '4', key: coin.keychains().create() },
24-
bitgo: { id: '5', key: coin.keychains().create() },
25-
},
26-
};
27-
28-
let customChangeKeySignatures: Record<string, string>;
29-
30-
const addressData = {
31-
chain: 11,
32-
index: 1,
33-
addressType: 'p2shP2wsh' as const,
34-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
35-
keychains: [
36-
{ pub: keys.change.user.key.pub! },
37-
{ pub: keys.change.backup.key.pub! },
38-
{ pub: keys.change.bitgo.key.pub! },
39-
],
40-
threshold: 2,
41-
};
42-
43-
const changeAddress = generateAddress(coin.name, addressData);
44-
45-
const changeWalletId = 'changeWalletId';
46-
const stubData = {
47-
signedSendingWallet: {
48-
keyIds: sinon.stub().returns([keys.send.user.id, keys.send.backup.id, keys.send.bitgo.id]),
49-
coinSpecific: sinon.stub().returns({ customChangeWalletId: changeWalletId }),
50-
},
51-
changeWallet: {
52-
keyIds: sinon.stub().returns([keys.change.user.id, keys.change.backup.id, keys.change.bitgo.id]),
53-
createAddress: sinon.stub().resolves(changeAddress),
54-
},
55-
};
56-
57-
before(() => {
58-
const signerKey = BIP32.fromBase58(keys.send.user.key.prv!);
59-
const sign = ({ key }) => Buffer.from(message.signMessage(key.pub!, signerKey.privateKey!)).toString('hex');
60-
customChangeKeySignatures = {
61-
user: sign(keys.change.user),
62-
backup: sign(keys.change.backup),
63-
bitgo: sign(keys.change.bitgo),
64-
};
65-
});
66-
67-
it('should consider addresses derived from the custom change keys as internal spends', async () => {
68-
const signedSendingWallet = sinon.createStubInstance(Wallet, stubData.signedSendingWallet as any);
69-
const changeWallet = sinon.createStubInstance(Wallet, stubData.changeWallet as any);
70-
71-
sinon.stub(coin, 'keychains').returns({
72-
get: sinon.stub().callsFake(({ id }) => {
73-
switch (id) {
74-
case keys.send.user.id:
75-
return Promise.resolve({ id, ...keys.send.user.key });
76-
case keys.send.backup.id:
77-
return Promise.resolve({ id, ...keys.send.backup.key });
78-
case keys.send.bitgo.id:
79-
return Promise.resolve({ id, ...keys.send.bitgo.key });
80-
case keys.change.user.id:
81-
return Promise.resolve({ id, ...keys.change.user.key });
82-
case keys.change.backup.id:
83-
return Promise.resolve({ id, ...keys.change.backup.key });
84-
case keys.change.bitgo.id:
85-
return Promise.resolve({ id, ...keys.change.bitgo.key });
86-
}
87-
}),
88-
} as any);
89-
90-
sinon.stub(coin, 'wallets').returns({
91-
get: sinon.stub().callsFake(() => Promise.resolve(changeWallet)),
92-
} as any);
93-
94-
const outputAmount = 10000;
95-
const recipients = [];
96-
97-
sinon.stub(coin, 'explainTransaction').resolves({
98-
outputs: [],
99-
changeOutputs: [
100-
{
101-
address: changeAddress,
102-
amount: outputAmount,
103-
},
104-
],
105-
} as any);
106-
107-
signedSendingWallet._wallet = signedSendingWallet._wallet || {
108-
customChangeKeySignatures,
109-
};
110-
111-
const parsedTransaction = await coin.parseTransaction({
112-
txParams: { changeAddress, recipients },
113-
txPrebuild: { txHex: '' },
114-
wallet: signedSendingWallet as any,
115-
verification: {
116-
addresses: {
117-
[changeAddress]: {
118-
chain: addressData.chain,
119-
index: addressData.index,
120-
},
121-
},
122-
},
1+
import assert from 'node:assert/strict';
2+
3+
import { CoinName, fixedScriptWallet, BIP32, message } from '@bitgo/wasm-utxo';
4+
import * as utxolib from '@bitgo/utxo-lib';
5+
import { testutil } from '@bitgo/utxo-lib';
6+
7+
import { explainPsbt as explainPsbtUtxolib, explainPsbtWasm } from '../../src/transaction/fixedScript';
8+
import { verifyKeySignature } from '../../src/verifyKey';
9+
import { SdkBackend } from '../../src/transaction';
10+
11+
function explainPsbt(
12+
psbt: utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt,
13+
walletKeys: utxolib.bitgo.RootWalletKeys,
14+
customChangeWalletKeys: utxolib.bitgo.RootWalletKeys | undefined,
15+
coin: CoinName
16+
) {
17+
if (psbt instanceof fixedScriptWallet.BitGoPsbt) {
18+
return explainPsbtWasm(psbt, fixedScriptWallet.RootWalletKeys.from(walletKeys), {
19+
replayProtection: { publicKeys: [] },
20+
customChangeWalletXpubs: customChangeWalletKeys
21+
? fixedScriptWallet.RootWalletKeys.from(customChangeWalletKeys)
22+
: undefined,
12323
});
24+
} else {
25+
return explainPsbtUtxolib(psbt, { pubs: walletKeys, customChangePubs: customChangeWalletKeys }, coin);
26+
}
27+
}
28+
29+
function describeWithBackend(sdkBackend: SdkBackend) {
30+
describe(`Custom Change Wallets (sdkBackend=${sdkBackend})`, function () {
31+
const network = utxolib.networks.bitcoin;
32+
const rootWalletKeys = testutil.getDefaultWalletKeys();
33+
const customChangeWalletKeys = testutil.getWalletKeysForSeed('custom change');
34+
35+
const inputs: testutil.Input[] = [{ scriptType: 'p2sh', value: BigInt(10000) }];
36+
const outputs: testutil.Output[] = [
37+
// regular change (uses rootWalletKeys via default)
38+
{ scriptType: 'p2sh', value: BigInt(3000) },
39+
// custom change (bip32Derivation from customChangeWalletKeys, not added as global xpubs)
40+
{ scriptType: 'p2sh', value: BigInt(3000), walletKeys: customChangeWalletKeys },
41+
// external (no derivation info)
42+
{ scriptType: 'p2sh', value: BigInt(3000), walletKeys: null },
43+
];
44+
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+
);
55+
56+
if (sdkBackend === 'wasm-utxo') {
57+
psbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbt.toBuffer(), 'btc');
58+
}
59+
60+
it('classifies custom change output when customChangePubs is provided', function () {
61+
const explanation = explainPsbt(psbt, rootWalletKeys, customChangeWalletKeys, 'btc');
12462

125-
should.exist(parsedTransaction.outputs[0]);
126-
parsedTransaction.outputs[0].should.deepEqual({
127-
address: changeAddress,
128-
amount: outputAmount,
129-
external: false,
130-
needsCustomChangeKeySignatureVerification: true,
63+
assert.strictEqual(explanation.changeOutputs.length, 1);
64+
assert.strictEqual(explanation.changeOutputs[0].amount, '3000');
65+
66+
assert.ok(explanation.customChangeOutputs);
67+
assert.strictEqual(explanation.customChangeOutputs.length, 1);
68+
assert.strictEqual(explanation.customChangeOutputs[0].amount, '3000');
69+
assert.strictEqual(explanation.customChangeAmount, '3000');
70+
71+
assert.strictEqual(explanation.outputs.length, 1);
72+
assert.strictEqual(explanation.outputs[0].amount, '3000');
13173
});
13274

133-
(coin.explainTransaction as any).restore();
134-
(coin.wallets as any).restore();
135-
(coin.keychains as any).restore();
136-
});
75+
it('classifies custom change output as external without customChangePubs', function () {
76+
const explanation = explainPsbt(psbt, rootWalletKeys, undefined, 'btc');
13777

138-
it('should reject invalid custom change key signatures before calling explainTransaction', async () => {
139-
const wrongKey = BIP32.fromBase58(coin.keychains().create().prv!);
140-
const sign = ({ key }) => Buffer.from(message.signMessage(key.pub!, wrongKey.privateKey!)).toString('hex');
141-
const invalidSignatures = {
142-
user: sign(keys.change.user),
143-
backup: sign(keys.change.backup),
144-
bitgo: sign(keys.change.bitgo),
145-
};
146-
147-
const signedSendingWallet = sinon.createStubInstance(Wallet, stubData.signedSendingWallet as any);
148-
const changeWallet = sinon.createStubInstance(Wallet, stubData.changeWallet as any);
149-
150-
sinon.stub(coin, 'keychains').returns({
151-
get: sinon.stub().callsFake(({ id }) => {
152-
switch (id) {
153-
case keys.send.user.id:
154-
return Promise.resolve({ id, ...keys.send.user.key });
155-
case keys.send.backup.id:
156-
return Promise.resolve({ id, ...keys.send.backup.key });
157-
case keys.send.bitgo.id:
158-
return Promise.resolve({ id, ...keys.send.bitgo.key });
159-
case keys.change.user.id:
160-
return Promise.resolve({ id, ...keys.change.user.key });
161-
case keys.change.backup.id:
162-
return Promise.resolve({ id, ...keys.change.backup.key });
163-
case keys.change.bitgo.id:
164-
return Promise.resolve({ id, ...keys.change.bitgo.key });
165-
}
166-
}),
167-
} as any);
168-
169-
sinon.stub(coin, 'wallets').returns({
170-
get: sinon.stub().callsFake(() => Promise.resolve(changeWallet)),
171-
} as any);
172-
173-
const explainStub = sinon.stub(coin, 'explainTransaction');
174-
175-
signedSendingWallet._wallet = signedSendingWallet._wallet || {
176-
customChangeKeySignatures: invalidSignatures,
177-
};
178-
179-
try {
180-
await coin.parseTransaction({
181-
txParams: { recipients: [] },
182-
txPrebuild: { txHex: '' },
183-
wallet: signedSendingWallet as any,
184-
verification: {},
185-
});
186-
assert.fail('parseTransaction should have thrown for invalid custom change key signatures');
187-
} catch (e) {
188-
assert.ok(e instanceof Error);
189-
assert.match(e.message, /failed to verify custom change .* key signature/);
190-
}
78+
assert.strictEqual(explanation.changeOutputs.length, 1);
79+
assert.strictEqual(explanation.changeOutputs[0].amount, '3000');
80+
81+
assert.strictEqual(explanation.customChangeOutputs?.length ?? 0, 0);
19182

192-
assert.strictEqual(explainStub.called, false, 'explainTransaction should not have been called');
83+
// custom change + external both treated as external outputs
84+
assert.strictEqual(explanation.outputs.length, 2);
85+
});
19386

194-
explainStub.restore();
195-
(coin.wallets as any).restore();
196-
(coin.keychains as any).restore();
87+
it('verifies valid custom change key signatures', function () {
88+
const userPrivateKey = BIP32.fromBase58(rootWalletKeys.triple[0].toBase58()).privateKey!;
89+
const userPub = rootWalletKeys.triple[0].neutered().toBase58();
90+
91+
for (const key of customChangeWalletKeys.triple) {
92+
const pub = key.neutered().toBase58();
93+
const signature = Buffer.from(message.signMessage(pub, userPrivateKey)).toString('hex');
94+
assert.ok(
95+
verifyKeySignature({ userKeychain: { pub: userPub }, keychainToVerify: { pub }, keySignature: signature })
96+
);
97+
}
98+
});
99+
100+
it('rejects invalid custom change key signatures', function () {
101+
const wrongKey = BIP32.fromBase58(testutil.getWalletKeysForSeed('wrong').triple[0].toBase58());
102+
const userPub = rootWalletKeys.triple[0].neutered().toBase58();
103+
104+
for (const key of customChangeWalletKeys.triple) {
105+
const pub = key.neutered().toBase58();
106+
const badSignature = Buffer.from(message.signMessage(pub, wrongKey.privateKey!)).toString('hex');
107+
assert.strictEqual(
108+
verifyKeySignature({ userKeychain: { pub: userPub }, keychainToVerify: { pub }, keySignature: badSignature }),
109+
false
110+
);
111+
}
112+
});
197113
});
198-
});
114+
}
115+
116+
describeWithBackend('utxolib');
117+
describeWithBackend('wasm-utxo');

0 commit comments

Comments
 (0)