Skip to content

Commit f25907c

Browse files
OttoAllmendingerllm-git
andcommitted
refactor(utxo-lib): improve type safety and structure of WalletOutput module
Split PSBT output handling into smaller functions for better maintainability. Add type safety with Payment interface and explicit error handling. Extract BIP32 derivation logic and add comprehensive tests for script ID validation. Ticket: BTC-1966 Co-authored-by: llm-git <[email protected]>
1 parent 32b8a8f commit f25907c

File tree

2 files changed

+318
-72
lines changed

2 files changed

+318
-72
lines changed
Lines changed: 199 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,253 @@
1-
import { taproot } from 'bitcoinjs-lib';
2-
import { PsbtOutputUpdate } from 'bip174/src/lib/interfaces';
1+
import { Payment, taproot } from 'bitcoinjs-lib';
2+
import { PsbtOutput, PsbtOutputUpdate } from 'bip174/src/lib/interfaces';
33
import { UtxoPsbt } from '../UtxoPsbt';
4-
import { RootWalletKeys } from './WalletKeys';
4+
import { RootWalletKeys, DerivedWalletKeys } from './WalletKeys';
55
import { ChainCode, scriptTypeForChain } from './chains';
66
import { createOutputScript2of3, createPaymentP2tr, createPaymentP2trMusig2, toXOnlyPublicKey } from '../outputScripts';
7+
import { getScriptIdFromPath, ScriptId } from './ScriptId';
8+
import * as assert from 'node:assert';
79

810
/**
9-
* Add a verifiable wallet output to the PSBT. The output and all data
10-
* needed to verify it from public keys only are added to the PSBT.
11-
* Typically these are change outputs.
11+
* Get the BIP32 derivation data for a PSBT output.
1212
*
13-
* @param psbt the PSBT to add change output to
14-
* @param rootWalletKeys keys that will be able to spend the output
15-
* @param chain chain code to use for deriving scripts (and to determine script
16-
* type) chain is an API parameter in the BitGo API, and may be
17-
* any valid ChainCode
18-
* @param index derivation index for the change address
19-
* @param value value of the change output
13+
* @param rootWalletKeys root wallet keys used for master fingerprints
14+
* @param walletKeys derived wallet keys for the specific chain and index
15+
* @param scriptType the script type to determine whether to use regular or taproot derivation
16+
* @param payment optional payment object for taproot scripts to calculate leaf hashes
17+
* @returns Object containing BIP32 derivation data
2018
*/
21-
export function addWalletOutputToPsbt(
22-
psbt: UtxoPsbt,
19+
export function getPsbtBip32DerivationOutputUpdate(
2320
rootWalletKeys: RootWalletKeys,
24-
chain: ChainCode,
25-
index: number,
26-
value: bigint
27-
): void {
28-
const walletKeys = rootWalletKeys.deriveForChainAndIndex(chain, index);
29-
const scriptType = scriptTypeForChain(chain);
21+
walletKeys: DerivedWalletKeys,
22+
scriptType: string,
23+
payment?: Payment
24+
): PsbtOutputUpdate {
25+
const update: PsbtOutputUpdate = {};
26+
3027
if (scriptType === 'p2tr' || scriptType === 'p2trMusig2') {
31-
const payment =
32-
scriptType === 'p2tr' ? createPaymentP2tr(walletKeys.publicKeys) : createPaymentP2trMusig2(walletKeys.publicKeys);
33-
psbt.addOutput({ script: payment.output!, value });
28+
if (!payment || !payment.redeems) {
29+
throw new Error('Payment object with redeems is required for taproot derivation');
30+
}
31+
32+
const allLeafHashes = payment.redeems.map((r) => taproot.hashTapLeaf(r.output!));
33+
34+
update.tapBip32Derivation = [0, 1, 2].map((idx) => {
35+
const pubkey = toXOnlyPublicKey(walletKeys.triple[idx].publicKey);
36+
const leafHashes: Buffer[] = [];
37+
38+
assert(payment.redeems);
39+
payment.redeems.forEach((r: any, redeemIdx: number) => {
40+
if (r.pubkeys!.find((pk: Buffer) => pk.equals(pubkey))) {
41+
leafHashes.push(allLeafHashes[redeemIdx]);
42+
}
43+
});
44+
45+
return {
46+
leafHashes,
47+
pubkey,
48+
path: walletKeys.paths[idx],
49+
masterFingerprint: rootWalletKeys.triple[idx].fingerprint,
50+
};
51+
});
3452
} else {
35-
const { scriptPubKey: script } = createOutputScript2of3(walletKeys.publicKeys, scriptType);
36-
psbt.addOutput({ script, value });
53+
update.bip32Derivation = [0, 1, 2].map((idx) => ({
54+
pubkey: walletKeys.triple[idx].publicKey,
55+
path: walletKeys.paths[idx],
56+
masterFingerprint: rootWalletKeys.triple[idx].fingerprint,
57+
}));
3758
}
38-
updateWalletOutputForPsbt(psbt, rootWalletKeys, psbt.data.outputs.length - 1, chain, index);
59+
60+
return update;
3961
}
4062

4163
/**
42-
* Update the wallet output with the required information when necessary. If the
43-
* information is there already, it will skip over it.
44-
*
45-
* This function assumes that the output script and value have already been set.
64+
* Get the PSBT output update object from a PSBT output and output script.
4665
*
47-
* @param psbt the PSBT to update change output at
66+
* @param output the PSBT output to get update for
67+
* @param outputScript the output script
4868
* @param rootWalletKeys keys that will be able to spend the output
49-
* @param outputIndex output index where to update the output
50-
* @param chain chain code to use for deriving scripts (and to determine script
51-
* type) chain is an API parameter in the BitGo API, and may be
52-
* any valid ChainCode
69+
* @param chain chain code to use for deriving scripts (and to determine script type)
5370
* @param index derivation index for the change address
54-
* @param value value of the change output
71+
* @returns PsbtOutputUpdate object with the required information
5572
*/
56-
export function updateWalletOutputForPsbt(
57-
psbt: UtxoPsbt,
73+
export function getPsbtOutputUpdateFromPsbtOutput(
74+
output: PsbtOutput,
75+
outputScript: Buffer,
5876
rootWalletKeys: RootWalletKeys,
59-
outputIndex: number,
6077
chain: ChainCode,
6178
index: number
62-
): void {
63-
if (psbt.data.outputs.length <= outputIndex) {
64-
throw new Error(
65-
`outputIndex (${outputIndex}) is too large for the number of outputs (${psbt.data.outputs.length})`
66-
);
67-
}
68-
69-
const outputScript = psbt.getOutputScript(outputIndex);
70-
79+
): PsbtOutputUpdate {
7180
const walletKeys = rootWalletKeys.deriveForChainAndIndex(chain, index);
7281
const scriptType = scriptTypeForChain(chain);
73-
const output = psbt.data.outputs[outputIndex];
7482
const update: PsbtOutputUpdate = {};
83+
7584
if (scriptType === 'p2tr' || scriptType === 'p2trMusig2') {
7685
const payment =
7786
scriptType === 'p2tr' ? createPaymentP2tr(walletKeys.publicKeys) : createPaymentP2trMusig2(walletKeys.publicKeys);
7887
if (!payment.output || !payment.output.equals(outputScript)) {
7988
throw new Error(`cannot update a p2tr output where the scripts do not match - Failing.`);
8089
}
81-
const allLeafHashes = payment.redeems!.map((r) => taproot.hashTapLeaf(r.output!));
8290

8391
if (!output.tapTree) {
8492
update.tapTree = payment.tapTree;
8593
}
8694
if (!output.tapInternalKey) {
8795
update.tapInternalKey = payment.internalPubkey;
8896
}
97+
8998
if (!output.tapBip32Derivation) {
90-
update.tapBip32Derivation = [0, 1, 2].map((idx) => {
91-
const pubkey = toXOnlyPublicKey(walletKeys.triple[idx].publicKey);
92-
const leafHashes: Buffer[] = [];
93-
payment.redeems!.forEach((r, idx) => {
94-
if (r.pubkeys!.find((pk) => pk.equals(pubkey))) {
95-
leafHashes.push(allLeafHashes[idx]);
96-
}
97-
});
98-
return {
99-
leafHashes,
100-
pubkey,
101-
path: walletKeys.paths[idx],
102-
masterFingerprint: rootWalletKeys.triple[idx].fingerprint,
103-
};
104-
});
99+
const derivationUpdate = getPsbtBip32DerivationOutputUpdate(rootWalletKeys, walletKeys, scriptType, payment);
100+
update.tapBip32Derivation = derivationUpdate.tapBip32Derivation;
105101
}
106102
} else {
107103
const { scriptPubKey, witnessScript, redeemScript } = createOutputScript2of3(walletKeys.publicKeys, scriptType);
108104
if (!scriptPubKey.equals(outputScript)) {
109105
throw new Error(`cannot update an output where the scripts do not match - Failing.`);
110106
}
107+
111108
if (!output.bip32Derivation) {
112-
update.bip32Derivation = [0, 1, 2].map((idx) => ({
113-
pubkey: walletKeys.triple[idx].publicKey,
114-
path: walletKeys.paths[idx],
115-
masterFingerprint: rootWalletKeys.triple[idx].fingerprint,
116-
}));
109+
const derivationUpdate = getPsbtBip32DerivationOutputUpdate(rootWalletKeys, walletKeys, scriptType);
110+
update.bip32Derivation = derivationUpdate.bip32Derivation;
117111
}
112+
118113
if (!output.witnessScript && witnessScript) {
119114
update.witnessScript = witnessScript;
120115
}
121116
if (!output.redeemScript && redeemScript) {
122117
update.redeemScript = redeemScript;
123118
}
124119
}
125-
psbt.updateOutput(outputIndex, update);
120+
121+
return update;
122+
}
123+
124+
/**
125+
* Get the PSBT output update object with the required information.
126+
*
127+
* @param psbt the PSBT to get output update for
128+
* @param rootWalletKeys keys that will be able to spend the output
129+
* @param outputIndex output index where to update the output
130+
* @param chain chain code to use for deriving scripts (and to determine script
131+
* type) chain is an API parameter in the BitGo API, and may be
132+
* any valid ChainCode
133+
* @param index derivation index for the change address
134+
* @returns PsbtOutputUpdate object with the required information
135+
*/
136+
export function getPsbtOutputUpdate(
137+
psbt: UtxoPsbt,
138+
rootWalletKeys: RootWalletKeys,
139+
outputIndex: number,
140+
chain: ChainCode,
141+
index: number
142+
): PsbtOutputUpdate {
143+
if (psbt.data.outputs.length <= outputIndex) {
144+
throw new Error(
145+
`outputIndex (${outputIndex}) is too large for the number of outputs (${psbt.data.outputs.length})`
146+
);
147+
}
148+
149+
const outputScript = psbt.getOutputScript(outputIndex);
150+
const output = psbt.data.outputs[outputIndex];
151+
152+
return getPsbtOutputUpdateFromPsbtOutput(output, outputScript, rootWalletKeys, chain, index);
153+
}
154+
155+
/**
156+
* Update the wallet output with the required information when necessary. If the
157+
* information is there already, it will skip over it.
158+
*
159+
* This function assumes that the output script and value have already been set.
160+
*
161+
* @param psbt the PSBT to update change output at
162+
* @param rootWalletKeys keys that will be able to spend the output
163+
* @param outputIndex output index where to update the output
164+
* @param chain chain code to use for deriving scripts (and to determine script
165+
* type) chain is an API parameter in the BitGo API, and may be
166+
* any valid ChainCode
167+
* @param index derivation index for the change address
168+
*/
169+
export function updateWalletOutputForPsbt(
170+
psbt: UtxoPsbt,
171+
rootWalletKeys: RootWalletKeys,
172+
outputIndex: number,
173+
chain: ChainCode,
174+
index: number
175+
): void {
176+
psbt.updateOutput(outputIndex, getPsbtOutputUpdate(psbt, rootWalletKeys, outputIndex, chain, index));
177+
}
178+
179+
/**
180+
* Add a verifiable wallet output to the PSBT. The output and all data
181+
* needed to verify it from public keys only are added to the PSBT.
182+
* Typically these are change outputs.
183+
*
184+
* @param psbt the PSBT to add change output to
185+
* @param rootWalletKeys keys that will be able to spend the output
186+
* @param chain chain code to use for deriving scripts (and to determine script
187+
* type) chain is an API parameter in the BitGo API, and may be
188+
* any valid ChainCode
189+
* @param index derivation index for the change address
190+
* @param value value of the change output
191+
*/
192+
export function addWalletOutputToPsbt(
193+
psbt: UtxoPsbt,
194+
rootWalletKeys: RootWalletKeys,
195+
chain: ChainCode,
196+
index: number,
197+
value: bigint
198+
): void {
199+
const walletKeys = rootWalletKeys.deriveForChainAndIndex(chain, index);
200+
const scriptType = scriptTypeForChain(chain);
201+
if (scriptType === 'p2tr' || scriptType === 'p2trMusig2') {
202+
const payment =
203+
scriptType === 'p2tr' ? createPaymentP2tr(walletKeys.publicKeys) : createPaymentP2trMusig2(walletKeys.publicKeys);
204+
psbt.addOutput({ script: payment.output!, value });
205+
} else {
206+
const { scriptPubKey: script } = createOutputScript2of3(walletKeys.publicKeys, scriptType);
207+
psbt.addOutput({ script, value });
208+
}
209+
updateWalletOutputForPsbt(psbt, rootWalletKeys, psbt.data.outputs.length - 1, chain, index);
210+
}
211+
212+
/**
213+
* Fold the script ids into a single script id, if they are all the same.
214+
* @param scriptIds
215+
*/
216+
function foldScriptIds(scriptIds: ScriptId[]): ScriptId {
217+
if (scriptIds.length === 0) {
218+
throw new Error('cannot fold empty script ids');
219+
}
220+
scriptIds.forEach((scriptId, i) => {
221+
if (scriptId.chain !== scriptIds[0].chain) {
222+
throw new Error(`chain mismatch: ${scriptId.chain} != ${scriptIds[0].chain}`);
223+
}
224+
if (scriptId.index !== scriptIds[0].index) {
225+
throw new Error(`index mismatch: ${scriptId.index} != ${scriptIds[0].index}`);
226+
}
227+
});
228+
return scriptIds[0];
229+
}
230+
231+
/**
232+
* Get the script id from the output.
233+
* The output can have either bip32Derivation or tapBip32Derivation, but not both.
234+
* @param output
235+
* @throws Error if neither or both bip32Derivation and tapBip32Derivation are present
236+
* @throws Error if the output is empty
237+
* @throws Error if we cannot fold the script ids into a single script id
238+
*/
239+
export function getScriptIdFromOutput(output: {
240+
bip32Derivation?: { path: string }[];
241+
tapBip32Derivation?: { path: string }[];
242+
}): ScriptId {
243+
if (output.bip32Derivation && output.tapBip32Derivation) {
244+
throw new Error('cannot get script id from output with both bip32Derivation and tapBip32Derivation');
245+
}
246+
if (output.bip32Derivation) {
247+
return foldScriptIds(output.bip32Derivation.map((d) => getScriptIdFromPath(d.path)));
248+
}
249+
if (output.tapBip32Derivation) {
250+
return foldScriptIds(output.tapBip32Derivation.map((d) => getScriptIdFromPath(d.path)));
251+
}
252+
throw new Error('cannot get script id from output without bip32Derivation or tapBip32Derivation');
126253
}

0 commit comments

Comments
 (0)