Skip to content

Commit 6b97016

Browse files
Merge pull request #5870 from BitGo/BTC-1966.getScriptId
feat(utxo-lib): introduce getScriptIdFromPath
2 parents 999014e + 0fa57dd commit 6b97016

File tree

9 files changed

+383
-119
lines changed

9 files changed

+383
-119
lines changed

modules/utxo-lib/src/bitgo/outputScripts.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as assert from 'assert';
22
import * as bitcoinjs from 'bitcoinjs-lib';
33

4-
import { Network, p2trPayments, supportsSegwit, supportsTaproot, taproot } from '..';
4+
import { Network, supportsSegwit, supportsTaproot } from '../networks';
5+
import * as p2trPayments from '../payments';
6+
import * as taproot from '../taproot';
57

68
import { isTriple, Triple, Tuple } from './types';
79

modules/utxo-lib/src/bitgo/parseInput.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint no-redeclare: 0 */
22
import * as opcodes from 'bitcoin-ops';
3-
import { TxInput, script as bscript } from 'bitcoinjs-lib';
3+
import { script as bscript, TxInput } from 'bitcoinjs-lib';
44

55
import { isTriple } from './types';
66
import { isScriptType2Of3 } from './outputScripts';
@@ -698,20 +698,3 @@ export function parsePubScript(
698698

699699
return result;
700700
}
701-
702-
export function getChainAndIndexFromPath(path: string): { chain: number; index: number } {
703-
const parts = path.split('/');
704-
if (parts.length <= 2) {
705-
throw new Error(`invalid path "${path}"`);
706-
}
707-
const chain = Number(parts[parts.length - 2]);
708-
const index = Number(parts[parts.length - 1]);
709-
if (isNaN(chain) || isNaN(index)) {
710-
throw new Error(`Could not parse chain and index into numbers from path ${path}`);
711-
}
712-
if (chain < 0 || index < 0) {
713-
throw new Error(`chain and index must be non-negative`);
714-
}
715-
716-
return { chain, index };
717-
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ChainCode, isChainCode } from './chains';
2+
3+
export type ScriptId = {
4+
chain: ChainCode;
5+
index: number;
6+
};
7+
8+
/**
9+
* Get the chain and index from a bip32 path.
10+
*
11+
* @param path
12+
*/
13+
export function getScriptIdFromPath(path: string): ScriptId {
14+
const parts = path.split('/');
15+
if (parts.length <= 2) {
16+
throw new Error(`invalid path "${path}"`);
17+
}
18+
const chain = Number(parts[parts.length - 2]);
19+
const index = Number(parts[parts.length - 1]);
20+
if (!isChainCode(chain)) {
21+
throw new Error(`invalid chain "${chain}"`);
22+
}
23+
if (!Number.isInteger(index) || index < 0) {
24+
throw new Error(`invalid index "${index}"`);
25+
}
26+
27+
return { chain, index };
28+
}
29+
30+
/** @deprecated use getScriptId */
31+
export const getChainAndIndexFromPath = getScriptIdFromPath;
Lines changed: 200 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,254 @@
1-
import { taproot } from 'bitcoinjs-lib';
2-
import { PsbtOutputUpdate } from 'bip174/src/lib/interfaces';
1+
import * as assert from 'assert';
2+
3+
import { Payment, taproot } from 'bitcoinjs-lib';
4+
import { PsbtOutput, PsbtOutputUpdate } from 'bip174/src/lib/interfaces';
35
import { UtxoPsbt } from '../UtxoPsbt';
4-
import { RootWalletKeys } from './WalletKeys';
6+
import { RootWalletKeys, DerivedWalletKeys } from './WalletKeys';
57
import { ChainCode, scriptTypeForChain } from './chains';
8+
import { getScriptIdFromPath, ScriptId } from './ScriptId';
69
import { createOutputScript2of3, createPaymentP2tr, createPaymentP2trMusig2, toXOnlyPublicKey } from '../outputScripts';
710

811
/**
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.
12+
* Get the BIP32 derivation data for a PSBT output.
1213
*
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
14+
* @param rootWalletKeys root wallet keys used for master fingerprints
15+
* @param walletKeys derived wallet keys for the specific chain and index
16+
* @param scriptType the script type to determine whether to use regular or taproot derivation
17+
* @param payment optional payment object for taproot scripts to calculate leaf hashes
18+
* @returns Object containing BIP32 derivation data
2019
*/
21-
export function addWalletOutputToPsbt(
22-
psbt: UtxoPsbt,
20+
export function getPsbtBip32DerivationOutputUpdate(
2321
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);
22+
walletKeys: DerivedWalletKeys,
23+
scriptType: string,
24+
payment?: Payment
25+
): PsbtOutputUpdate {
26+
const update: PsbtOutputUpdate = {};
27+
3028
if (scriptType === 'p2tr' || scriptType === 'p2trMusig2') {
31-
const payment =
32-
scriptType === 'p2tr' ? createPaymentP2tr(walletKeys.publicKeys) : createPaymentP2trMusig2(walletKeys.publicKeys);
33-
psbt.addOutput({ script: payment.output!, value });
29+
if (!payment || !payment.redeems) {
30+
throw new Error('Payment object with redeems is required for taproot derivation');
31+
}
32+
33+
const allLeafHashes = payment.redeems.map((r) => taproot.hashTapLeaf(r.output!));
34+
35+
update.tapBip32Derivation = [0, 1, 2].map((idx) => {
36+
const pubkey = toXOnlyPublicKey(walletKeys.triple[idx].publicKey);
37+
const leafHashes: Buffer[] = [];
38+
39+
assert(payment.redeems);
40+
payment.redeems.forEach((r: any, redeemIdx: number) => {
41+
if (r.pubkeys!.find((pk: Buffer) => pk.equals(pubkey))) {
42+
leafHashes.push(allLeafHashes[redeemIdx]);
43+
}
44+
});
45+
46+
return {
47+
leafHashes,
48+
pubkey,
49+
path: walletKeys.paths[idx],
50+
masterFingerprint: rootWalletKeys.triple[idx].fingerprint,
51+
};
52+
});
3453
} else {
35-
const { scriptPubKey: script } = createOutputScript2of3(walletKeys.publicKeys, scriptType);
36-
psbt.addOutput({ script, value });
54+
update.bip32Derivation = [0, 1, 2].map((idx) => ({
55+
pubkey: walletKeys.triple[idx].publicKey,
56+
path: walletKeys.paths[idx],
57+
masterFingerprint: rootWalletKeys.triple[idx].fingerprint,
58+
}));
3759
}
38-
updateWalletOutputForPsbt(psbt, rootWalletKeys, psbt.data.outputs.length - 1, chain, index);
60+
61+
return update;
3962
}
4063

4164
/**
42-
* Update the wallet output with the required information when necessary. If the
43-
* information is there already, it will skip over it.
65+
* Get the PSBT output update object from a PSBT output and output script.
4466
*
45-
* This function assumes that the output script and value have already been set.
46-
*
47-
* @param psbt the PSBT to update change output at
67+
* @param output the PSBT output to get update for
68+
* @param outputScript the output script
4869
* @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
70+
* @param chain chain code to use for deriving scripts (and to determine script type)
5371
* @param index derivation index for the change address
54-
* @param value value of the change output
72+
* @returns PsbtOutputUpdate object with the required information
5573
*/
56-
export function updateWalletOutputForPsbt(
57-
psbt: UtxoPsbt,
74+
export function getPsbtOutputUpdateFromPsbtOutput(
75+
output: PsbtOutput,
76+
outputScript: Buffer,
5877
rootWalletKeys: RootWalletKeys,
59-
outputIndex: number,
6078
chain: ChainCode,
6179
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-
80+
): PsbtOutputUpdate {
7181
const walletKeys = rootWalletKeys.deriveForChainAndIndex(chain, index);
7282
const scriptType = scriptTypeForChain(chain);
73-
const output = psbt.data.outputs[outputIndex];
7483
const update: PsbtOutputUpdate = {};
84+
7585
if (scriptType === 'p2tr' || scriptType === 'p2trMusig2') {
7686
const payment =
7787
scriptType === 'p2tr' ? createPaymentP2tr(walletKeys.publicKeys) : createPaymentP2trMusig2(walletKeys.publicKeys);
7888
if (!payment.output || !payment.output.equals(outputScript)) {
7989
throw new Error(`cannot update a p2tr output where the scripts do not match - Failing.`);
8090
}
81-
const allLeafHashes = payment.redeems!.map((r) => taproot.hashTapLeaf(r.output!));
8291

8392
if (!output.tapTree) {
8493
update.tapTree = payment.tapTree;
8594
}
8695
if (!output.tapInternalKey) {
8796
update.tapInternalKey = payment.internalPubkey;
8897
}
98+
8999
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-
});
100+
const derivationUpdate = getPsbtBip32DerivationOutputUpdate(rootWalletKeys, walletKeys, scriptType, payment);
101+
update.tapBip32Derivation = derivationUpdate.tapBip32Derivation;
105102
}
106103
} else {
107104
const { scriptPubKey, witnessScript, redeemScript } = createOutputScript2of3(walletKeys.publicKeys, scriptType);
108105
if (!scriptPubKey.equals(outputScript)) {
109106
throw new Error(`cannot update an output where the scripts do not match - Failing.`);
110107
}
108+
111109
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-
}));
110+
const derivationUpdate = getPsbtBip32DerivationOutputUpdate(rootWalletKeys, walletKeys, scriptType);
111+
update.bip32Derivation = derivationUpdate.bip32Derivation;
117112
}
113+
118114
if (!output.witnessScript && witnessScript) {
119115
update.witnessScript = witnessScript;
120116
}
121117
if (!output.redeemScript && redeemScript) {
122118
update.redeemScript = redeemScript;
123119
}
124120
}
125-
psbt.updateOutput(outputIndex, update);
121+
122+
return update;
123+
}
124+
125+
/**
126+
* Get the PSBT output update object with the required information.
127+
*
128+
* @param psbt the PSBT to get output update for
129+
* @param rootWalletKeys keys that will be able to spend the output
130+
* @param outputIndex output index where to update the output
131+
* @param chain chain code to use for deriving scripts (and to determine script
132+
* type) chain is an API parameter in the BitGo API, and may be
133+
* any valid ChainCode
134+
* @param index derivation index for the change address
135+
* @returns PsbtOutputUpdate object with the required information
136+
*/
137+
export function getPsbtOutputUpdate(
138+
psbt: UtxoPsbt,
139+
rootWalletKeys: RootWalletKeys,
140+
outputIndex: number,
141+
chain: ChainCode,
142+
index: number
143+
): PsbtOutputUpdate {
144+
if (psbt.data.outputs.length <= outputIndex) {
145+
throw new Error(
146+
`outputIndex (${outputIndex}) is too large for the number of outputs (${psbt.data.outputs.length})`
147+
);
148+
}
149+
150+
const outputScript = psbt.getOutputScript(outputIndex);
151+
const output = psbt.data.outputs[outputIndex];
152+
153+
return getPsbtOutputUpdateFromPsbtOutput(output, outputScript, rootWalletKeys, chain, index);
154+
}
155+
156+
/**
157+
* Update the wallet output with the required information when necessary. If the
158+
* information is there already, it will skip over it.
159+
*
160+
* This function assumes that the output script and value have already been set.
161+
*
162+
* @param psbt the PSBT to update change output at
163+
* @param rootWalletKeys keys that will be able to spend the output
164+
* @param outputIndex output index where to update the output
165+
* @param chain chain code to use for deriving scripts (and to determine script
166+
* type) chain is an API parameter in the BitGo API, and may be
167+
* any valid ChainCode
168+
* @param index derivation index for the change address
169+
*/
170+
export function updateWalletOutputForPsbt(
171+
psbt: UtxoPsbt,
172+
rootWalletKeys: RootWalletKeys,
173+
outputIndex: number,
174+
chain: ChainCode,
175+
index: number
176+
): void {
177+
psbt.updateOutput(outputIndex, getPsbtOutputUpdate(psbt, rootWalletKeys, outputIndex, chain, index));
178+
}
179+
180+
/**
181+
* Add a verifiable wallet output to the PSBT. The output and all data
182+
* needed to verify it from public keys only are added to the PSBT.
183+
* Typically these are change outputs.
184+
*
185+
* @param psbt the PSBT to add change output to
186+
* @param rootWalletKeys keys that will be able to spend the output
187+
* @param chain chain code to use for deriving scripts (and to determine script
188+
* type) chain is an API parameter in the BitGo API, and may be
189+
* any valid ChainCode
190+
* @param index derivation index for the change address
191+
* @param value value of the change output
192+
*/
193+
export function addWalletOutputToPsbt(
194+
psbt: UtxoPsbt,
195+
rootWalletKeys: RootWalletKeys,
196+
chain: ChainCode,
197+
index: number,
198+
value: bigint
199+
): void {
200+
const walletKeys = rootWalletKeys.deriveForChainAndIndex(chain, index);
201+
const scriptType = scriptTypeForChain(chain);
202+
if (scriptType === 'p2tr' || scriptType === 'p2trMusig2') {
203+
const payment =
204+
scriptType === 'p2tr' ? createPaymentP2tr(walletKeys.publicKeys) : createPaymentP2trMusig2(walletKeys.publicKeys);
205+
psbt.addOutput({ script: payment.output!, value });
206+
} else {
207+
const { scriptPubKey: script } = createOutputScript2of3(walletKeys.publicKeys, scriptType);
208+
psbt.addOutput({ script, value });
209+
}
210+
updateWalletOutputForPsbt(psbt, rootWalletKeys, psbt.data.outputs.length - 1, chain, index);
211+
}
212+
213+
/**
214+
* Fold the script ids into a single script id, if they are all the same.
215+
* @param scriptIds
216+
*/
217+
function foldScriptIds(scriptIds: ScriptId[]): ScriptId {
218+
if (scriptIds.length === 0) {
219+
throw new Error('cannot fold empty script ids');
220+
}
221+
scriptIds.forEach((scriptId, i) => {
222+
if (scriptId.chain !== scriptIds[0].chain) {
223+
throw new Error(`chain mismatch: ${scriptId.chain} != ${scriptIds[0].chain}`);
224+
}
225+
if (scriptId.index !== scriptIds[0].index) {
226+
throw new Error(`index mismatch: ${scriptId.index} != ${scriptIds[0].index}`);
227+
}
228+
});
229+
return scriptIds[0];
230+
}
231+
232+
/**
233+
* Get the script id from the output.
234+
* The output can have either bip32Derivation or tapBip32Derivation, but not both.
235+
* @param output
236+
* @throws Error if neither or both bip32Derivation and tapBip32Derivation are present
237+
* @throws Error if the output is empty
238+
* @throws Error if we cannot fold the script ids into a single script id
239+
*/
240+
export function getScriptIdFromOutput(output: {
241+
bip32Derivation?: { path: string }[];
242+
tapBip32Derivation?: { path: string }[];
243+
}): ScriptId {
244+
if (output.bip32Derivation && output.tapBip32Derivation) {
245+
throw new Error('cannot get script id from output with both bip32Derivation and tapBip32Derivation');
246+
}
247+
if (output.bip32Derivation) {
248+
return foldScriptIds(output.bip32Derivation.map((d) => getScriptIdFromPath(d.path)));
249+
}
250+
if (output.tapBip32Derivation) {
251+
return foldScriptIds(output.tapBip32Derivation.map((d) => getScriptIdFromPath(d.path)));
252+
}
253+
throw new Error('cannot get script id from output without bip32Derivation or tapBip32Derivation');
126254
}

0 commit comments

Comments
 (0)