|
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, |
123 | 23 | }); |
| 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'); |
124 | 62 |
|
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'); |
131 | 73 | }); |
132 | 74 |
|
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'); |
137 | 77 |
|
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); |
191 | 82 |
|
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 | + }); |
193 | 86 |
|
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 | + }); |
197 | 113 | }); |
198 | | -}); |
| 114 | +} |
| 115 | + |
| 116 | +describeWithBackend('utxolib'); |
| 117 | +describeWithBackend('wasm-utxo'); |
0 commit comments