|
1 | | -import * as utxolib from '@bitgo/utxo-lib'; |
2 | | -import { bip322 } from '@bitgo/utxo-core'; |
3 | | -import { bitgo } from '@bitgo/utxo-lib'; |
4 | | -import { ITransactionExplanation as BaseTransactionExplanation, Triple } from '@bitgo/sdk-core'; |
5 | | -import { BIP32 } from '@bitgo/wasm-utxo'; |
6 | | -import * as utxocore from '@bitgo/utxo-core'; |
| 1 | +import { ITransactionExplanation as BaseTransactionExplanation } from '@bitgo/sdk-core'; |
7 | 2 |
|
8 | 3 | import type { Bip322Message } from '../../abstractUtxoCoin'; |
9 | 4 | import type { Output, FixedScriptWalletOutput } from '../types'; |
10 | | -import { toExtendedAddressFormat } from '../recipient'; |
11 | | -import { getPayGoVerificationPubkey } from '../getPayGoVerificationPubkey'; |
12 | | -import { toBip32Triple } from '../../keychains'; |
13 | | -import { toUtxolibBIP32 } from '../../wasmUtil'; |
14 | | -import { getNetworkFromCoinName, UtxoCoinName } from '../../names'; |
15 | 5 |
|
16 | 6 | // ===== Transaction Explanation Type Definitions ===== |
17 | 7 |
|
@@ -61,344 +51,3 @@ export type TransactionExplanationUtxolibPsbt = TransactionExplanationWithSignat |
61 | 51 | export type TransactionExplanationDescriptor = TransactionExplanationWithSignatures<string, Output>; |
62 | 52 |
|
63 | 53 | export type TransactionExplanation = TransactionExplanationUtxolibPsbt | TransactionExplanationWasm; |
64 | | - |
65 | | -export type ChangeAddressInfo = { |
66 | | - address: string; |
67 | | - chain: number; |
68 | | - index: number; |
69 | | -}; |
70 | | - |
71 | | -function toChangeOutput( |
72 | | - txOutput: utxolib.TxOutput<number | bigint>, |
73 | | - coinName: UtxoCoinName, |
74 | | - changeInfo: ChangeAddressInfo[] | undefined |
75 | | -): FixedScriptWalletOutput | undefined { |
76 | | - if (!changeInfo) { |
77 | | - return undefined; |
78 | | - } |
79 | | - const address = toExtendedAddressFormat(txOutput.script, coinName); |
80 | | - const change = changeInfo.find((change) => change.address === address); |
81 | | - if (!change) { |
82 | | - return undefined; |
83 | | - } |
84 | | - return { |
85 | | - address, |
86 | | - amount: txOutput.value.toString(), |
87 | | - chain: change.chain, |
88 | | - index: change.index, |
89 | | - external: false, |
90 | | - }; |
91 | | -} |
92 | | - |
93 | | -function outputSum(outputs: { amount: string | number }[]): bigint { |
94 | | - return outputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0)); |
95 | | -} |
96 | | - |
97 | | -function explainCommon<TNumber extends number | bigint>( |
98 | | - tx: bitgo.UtxoTransaction<TNumber>, |
99 | | - params: { |
100 | | - changeInfo?: ChangeAddressInfo[]; |
101 | | - customChangeInfo?: ChangeAddressInfo[]; |
102 | | - feeInfo?: string; |
103 | | - }, |
104 | | - coinName: UtxoCoinName |
105 | | -) { |
106 | | - const displayOrder = ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs']; |
107 | | - const changeOutputs: FixedScriptWalletOutput[] = []; |
108 | | - const customChangeOutputs: FixedScriptWalletOutput[] = []; |
109 | | - const externalOutputs: Output[] = []; |
110 | | - |
111 | | - const { changeInfo, customChangeInfo } = params; |
112 | | - |
113 | | - tx.outs.forEach((currentOutput) => { |
114 | | - // Try to encode the script pubkey with an address. If it fails, try to parse it as an OP_RETURN output with the prefix. |
115 | | - // If that fails, then it is an unrecognized scriptPubkey and should fail |
116 | | - const currentAddress = toExtendedAddressFormat(currentOutput.script, coinName); |
117 | | - const currentAmount = BigInt(currentOutput.value); |
118 | | - |
119 | | - const changeOutput = toChangeOutput(currentOutput, coinName, changeInfo); |
120 | | - if (changeOutput) { |
121 | | - changeOutputs.push(changeOutput); |
122 | | - return; |
123 | | - } |
124 | | - |
125 | | - const customChangeOutput = toChangeOutput(currentOutput, coinName, customChangeInfo); |
126 | | - if (customChangeOutput) { |
127 | | - customChangeOutputs.push(customChangeOutput); |
128 | | - return; |
129 | | - } |
130 | | - |
131 | | - externalOutputs.push({ |
132 | | - address: currentAddress, |
133 | | - amount: currentAmount.toString(), |
134 | | - // If changeInfo has a length greater than or equal to zero, it means that the change information |
135 | | - // was provided to the function but the output was not identified as change. In this case, |
136 | | - // the output is external, and we can set it as so. If changeInfo is undefined, it means we were |
137 | | - // given no information about change outputs, so we can't determine anything about the output, |
138 | | - // so we leave it undefined. |
139 | | - external: changeInfo ? true : undefined, |
140 | | - }); |
141 | | - }); |
142 | | - |
143 | | - const outputDetails = { |
144 | | - outputs: externalOutputs, |
145 | | - outputAmount: outputSum(externalOutputs).toString(), |
146 | | - |
147 | | - changeOutputs, |
148 | | - changeAmount: outputSum(changeOutputs).toString(), |
149 | | - |
150 | | - customChangeAmount: outputSum(customChangeOutputs).toString(), |
151 | | - customChangeOutputs, |
152 | | - }; |
153 | | - |
154 | | - let fee: string | undefined; |
155 | | - let locktime: number | undefined; |
156 | | - |
157 | | - if (params.feeInfo) { |
158 | | - displayOrder.push('fee'); |
159 | | - fee = params.feeInfo; |
160 | | - } |
161 | | - |
162 | | - if (Number.isInteger(tx.locktime) && tx.locktime > 0) { |
163 | | - displayOrder.push('locktime'); |
164 | | - locktime = tx.locktime; |
165 | | - } |
166 | | - |
167 | | - return { displayOrder, id: tx.getId(), ...outputDetails, fee, locktime }; |
168 | | -} |
169 | | - |
170 | | -function getRootWalletKeys(params: { pubs?: bitgo.RootWalletKeys | string[] }): bitgo.RootWalletKeys | undefined { |
171 | | - if (params.pubs instanceof bitgo.RootWalletKeys) { |
172 | | - return params.pubs; |
173 | | - } |
174 | | - const keys = params.pubs?.map((xpub) => toUtxolibBIP32(BIP32.fromBase58(xpub))); |
175 | | - return keys && keys.length === 3 ? new bitgo.RootWalletKeys(keys as Triple<utxolib.BIP32Interface>) : undefined; |
176 | | -} |
177 | | - |
178 | | -function getPsbtInputSignaturesCount( |
179 | | - psbt: bitgo.UtxoPsbt, |
180 | | - params: { |
181 | | - pubs?: bitgo.RootWalletKeys | string[]; |
182 | | - } |
183 | | -) { |
184 | | - const rootWalletKeys = getRootWalletKeys(params); |
185 | | - return rootWalletKeys |
186 | | - ? bitgo.getSignatureValidationArrayPsbt(psbt, rootWalletKeys).map((sv) => sv[1].filter((v) => v).length) |
187 | | - : (Array(psbt.data.inputs.length) as number[]).fill(0); |
188 | | -} |
189 | | - |
190 | | -function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) { |
191 | | - const derivations = output.bip32Derivation ?? output.tapBip32Derivation ?? undefined; |
192 | | - if (!derivations) { |
193 | | - return undefined; |
194 | | - } |
195 | | - const paths = derivations.map((d) => d.path); |
196 | | - if (!paths || paths.length !== 3) { |
197 | | - throw new Error('expected 3 paths in bip32Derivation or tapBip32Derivation'); |
198 | | - } |
199 | | - if (!paths.every((p) => paths[0] === p)) { |
200 | | - throw new Error('expected all paths to be the same'); |
201 | | - } |
202 | | - |
203 | | - paths.forEach((path) => { |
204 | | - if (paths[0] !== path) { |
205 | | - throw new Error( |
206 | | - 'Unable to get a single chain and index on the output because there are different paths for different keys' |
207 | | - ); |
208 | | - } |
209 | | - }); |
210 | | - return utxolib.bitgo.getChainAndIndexFromPath(paths[0]); |
211 | | -} |
212 | | - |
213 | | -function getChangeInfo( |
214 | | - psbt: bitgo.UtxoPsbt, |
215 | | - walletKeys?: Triple<BIP32> | Triple<utxolib.BIP32Interface> |
216 | | -): ChangeAddressInfo[] | undefined { |
217 | | - let utxolibKeys: Triple<utxolib.BIP32Interface>; |
218 | | - try { |
219 | | - utxolibKeys = walletKeys |
220 | | - ? (walletKeys.map((k) => toUtxolibBIP32(k)) as Triple<utxolib.BIP32Interface>) |
221 | | - : utxolib.bitgo.getSortedRootNodes(psbt); |
222 | | - } catch (e) { |
223 | | - if (e instanceof utxolib.bitgo.ErrorNoMultiSigInputFound) { |
224 | | - return undefined; |
225 | | - } |
226 | | - throw e; |
227 | | - } |
228 | | - |
229 | | - return utxolib.bitgo.findWalletOutputIndices(psbt, utxolibKeys).map((i) => { |
230 | | - const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]); |
231 | | - if (!derivationInformation) { |
232 | | - throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation'); |
233 | | - } |
234 | | - return { |
235 | | - address: utxolib.address.fromOutputScript(psbt.txOutputs[i].script, psbt.network), |
236 | | - external: false, |
237 | | - ...derivationInformation, |
238 | | - }; |
239 | | - }); |
240 | | -} |
241 | | - |
242 | | -/** |
243 | | - * Extract PayGo address proof information from the PSBT if present |
244 | | - * @returns Information about the PayGo proof, including the output index and address |
245 | | - */ |
246 | | -function getPayGoVerificationInfo( |
247 | | - psbt: bitgo.UtxoPsbt, |
248 | | - coinName: UtxoCoinName |
249 | | -): { outputIndex: number; verificationPubkey: string } | undefined { |
250 | | - let outputIndex: number | undefined = undefined; |
251 | | - let address: string | undefined = undefined; |
252 | | - // Check if this PSBT has any PayGo address proofs |
253 | | - if (!utxocore.paygo.psbtOutputIncludesPaygoAddressProof(psbt)) { |
254 | | - return undefined; |
255 | | - } |
256 | | - |
257 | | - // This pulls the pubkey depending on given network |
258 | | - const verificationPubkey = getPayGoVerificationPubkey(coinName); |
259 | | - // find which output index that contains the PayGo proof |
260 | | - outputIndex = utxocore.paygo.getPayGoAddressProofOutputIndex(psbt); |
261 | | - if (outputIndex === undefined || !verificationPubkey) { |
262 | | - return undefined; |
263 | | - } |
264 | | - const network = getNetworkFromCoinName(coinName); |
265 | | - const output = psbt.txOutputs[outputIndex]; |
266 | | - address = utxolib.address.fromOutputScript(output.script, network); |
267 | | - if (!address) { |
268 | | - throw new Error(`Can not derive address ${address} Pay Go Attestation.`); |
269 | | - } |
270 | | - |
271 | | - return { outputIndex, verificationPubkey }; |
272 | | -} |
273 | | - |
274 | | -/** |
275 | | - * Extract the BIP322 messages and addresses from the PSBT inputs and perform |
276 | | - * verification on the transaction to ensure that it meets the BIP322 requirements. |
277 | | - * @returns An array of objects containing the message and address for each input, |
278 | | - * or undefined if no BIP322 messages are found. |
279 | | - */ |
280 | | -function getBip322MessageInfoAndVerify(psbt: bitgo.UtxoPsbt, coinName: UtxoCoinName): Bip322Message[] | undefined { |
281 | | - const network = getNetworkFromCoinName(coinName); |
282 | | - const bip322Messages: { message: string; address: string }[] = []; |
283 | | - for (let i = 0; i < psbt.data.inputs.length; i++) { |
284 | | - const message = bip322.getBip322ProofMessageAtIndex(psbt, i); |
285 | | - if (message) { |
286 | | - const input = psbt.data.inputs[i]; |
287 | | - if (!input.witnessUtxo) { |
288 | | - throw new Error(`Missing witnessUtxo for input index ${i}`); |
289 | | - } |
290 | | - const scriptPubKey = input.witnessUtxo.script; |
291 | | - |
292 | | - // Verify that the toSpend transaction can be recreated in the PSBT and is encoded correctly in the nonWitnessUtxo |
293 | | - const toSpend = bip322.buildToSpendTransaction(scriptPubKey, message); |
294 | | - |
295 | | - // Verify that the toSpend transaction ID matches the input's referenced transaction ID |
296 | | - if (toSpend.getId() !== utxolib.bitgo.getOutputIdForInput(psbt.txInputs[i]).txid) { |
297 | | - throw new Error(`ToSpend transaction ID does not match the input at index ${i}`); |
298 | | - } |
299 | | - |
300 | | - // Verify the input specifics |
301 | | - if (psbt.txInputs[i].sequence !== 0) { |
302 | | - throw new Error(`Unexpected sequence number at input index ${i}: ${psbt.txInputs[i].sequence}. Expected 0.`); |
303 | | - } |
304 | | - if (psbt.txInputs[i].index !== 0) { |
305 | | - throw new Error(`Unexpected input index at position ${i}: ${psbt.txInputs[i].index}. Expected 0.`); |
306 | | - } |
307 | | - |
308 | | - bip322Messages.push({ |
309 | | - message: message.toString('utf8'), |
310 | | - address: utxolib.address.fromOutputScript(scriptPubKey, network), |
311 | | - }); |
312 | | - } |
313 | | - } |
314 | | - |
315 | | - if (bip322Messages.length > 0) { |
316 | | - // If there is a BIP322 message in any input, all inputs must have one. |
317 | | - if (bip322Messages.length !== psbt.data.inputs.length) { |
318 | | - throw new Error('Inconsistent BIP322 messages across inputs.'); |
319 | | - } |
320 | | - |
321 | | - // Verify the transaction specifics for BIP322 |
322 | | - if (psbt.version !== 0 && psbt.version !== 2) { |
323 | | - throw new Error(`Unsupported PSBT version for BIP322: ${psbt.version}. Expected 0 `); |
324 | | - } |
325 | | - if ( |
326 | | - psbt.data.outputs.length !== 1 || |
327 | | - psbt.txOutputs[0].script.toString('hex') !== '6a' || |
328 | | - psbt.txOutputs[0].value !== 0n |
329 | | - ) { |
330 | | - throw new Error(`Invalid PSBT outputs for BIP322. Expected exactly one OP_RETURN output with zero value.`); |
331 | | - } |
332 | | - |
333 | | - return bip322Messages; |
334 | | - } |
335 | | - |
336 | | - return undefined; |
337 | | -} |
338 | | - |
339 | | -/** |
340 | | - * Decompose a raw psbt into useful information, such as the total amounts, |
341 | | - * change amounts, and transaction outputs. |
342 | | - * |
343 | | - * @param psbt {bitgo.UtxoPsbt} The PSBT to explain |
344 | | - * @param pubs {bitgo.RootWalletKeys | string[]} The public keys to use for the explanation |
345 | | - * @param coinName {UtxoCoinName} The coin name to use for the explanation |
346 | | - * @param strict {boolean} Whether to throw an error if the PayGo address proof is invalid |
347 | | - */ |
348 | | -export function explainPsbt( |
349 | | - psbt: bitgo.UtxoPsbt, |
350 | | - params: { |
351 | | - pubs?: bitgo.RootWalletKeys | string[]; |
352 | | - customChangePubs?: bitgo.RootWalletKeys | string[]; |
353 | | - }, |
354 | | - coinName: UtxoCoinName, |
355 | | - { strict = true }: { strict?: boolean } = {} |
356 | | -): TransactionExplanationUtxolibPsbt { |
357 | | - const network = getNetworkFromCoinName(coinName); |
358 | | - const payGoVerificationInfo = getPayGoVerificationInfo(psbt, coinName); |
359 | | - if (payGoVerificationInfo) { |
360 | | - try { |
361 | | - utxocore.paygo.verifyPayGoAddressProof( |
362 | | - psbt, |
363 | | - payGoVerificationInfo.outputIndex, |
364 | | - Buffer.from(BIP32.fromBase58(payGoVerificationInfo.verificationPubkey).publicKey) |
365 | | - ); |
366 | | - } catch (e) { |
367 | | - if (strict) { |
368 | | - throw e; |
369 | | - } |
370 | | - console.error(e); |
371 | | - } |
372 | | - } |
373 | | - |
374 | | - const messages = getBip322MessageInfoAndVerify(psbt, coinName); |
375 | | - const changeInfo = getChangeInfo(psbt); |
376 | | - const customChangeInfo = params.customChangePubs |
377 | | - ? getChangeInfo(psbt, toBip32Triple(params.customChangePubs)) |
378 | | - : undefined; |
379 | | - const tx = psbt.getUnsignedTx(); |
380 | | - const common = explainCommon(tx, { ...params, changeInfo, customChangeInfo }, coinName); |
381 | | - const inputSignaturesCount = getPsbtInputSignaturesCount(psbt, params); |
382 | | - |
383 | | - // Set fee from subtracting inputs from outputs |
384 | | - const outputAmount = psbt.txOutputs.reduce((cumulative, curr) => cumulative + BigInt(curr.value), BigInt(0)); |
385 | | - const inputAmount = psbt.txInputs.reduce((cumulative, txInput, i) => { |
386 | | - const data = psbt.data.inputs[i]; |
387 | | - if (data.witnessUtxo) { |
388 | | - return cumulative + BigInt(data.witnessUtxo.value); |
389 | | - } else if (data.nonWitnessUtxo) { |
390 | | - const tx = bitgo.createTransactionFromBuffer<bigint>(data.nonWitnessUtxo, network, { amountType: 'bigint' }); |
391 | | - return cumulative + BigInt(tx.outs[txInput.index].value); |
392 | | - } else { |
393 | | - throw new Error('could not find value on input'); |
394 | | - } |
395 | | - }, BigInt(0)); |
396 | | - |
397 | | - return { |
398 | | - ...common, |
399 | | - fee: (inputAmount - outputAmount).toString(), |
400 | | - inputSignatures: inputSignaturesCount, |
401 | | - signatures: inputSignaturesCount.reduce((prev, curr) => (curr > prev ? curr : prev), 0), |
402 | | - messages, |
403 | | - }; |
404 | | -} |
0 commit comments