Skip to content

Commit e5b5188

Browse files
Merge pull request #5252 from BitGo/BTC-1450.impl-explainTx-descriptors-wallets
feat(abstract-utxo): add explainPsbt function for descriptor wallets
2 parents f51c859 + 897d369 commit e5b5188

File tree

7 files changed

+131
-6
lines changed

7 files changed

+131
-6
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
.nyc_output/
22
dist/
3+
*.json

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export function isWalletOutput(output: Output): output is FixedScriptWalletOutpu
143143

144144
export interface TransactionExplanation extends BaseTransactionExplanation<string, string> {
145145
locktime: number;
146+
/** NOTE: this actually only captures external outputs */
146147
outputs: Output[];
147148
changeOutputs: Output[];
148149

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
import { ITransactionRecipient } from '@bitgo/sdk-core';
3+
4+
import * as coreDescriptors from '../../core/descriptor';
5+
import { ParsedOutput } from '../../core/descriptor/psbt/parse';
6+
import { toExtendedAddressFormat } from '../recipient';
7+
import { TransactionExplanation } from '../../abstractUtxoCoin';
8+
9+
function toRecipient(output: ParsedOutput, network: utxolib.Network): ITransactionRecipient {
10+
return {
11+
address: toExtendedAddressFormat(output.script, network),
12+
amount: output.value.toString(),
13+
};
14+
}
15+
16+
function sumValues(arr: { value: bigint }[]): bigint {
17+
return arr.reduce((sum, e) => sum + e.value, BigInt(0));
18+
}
19+
20+
function getInputSignaturesForInputIndex(psbt: utxolib.bitgo.UtxoPsbt, inputIndex: number): number {
21+
const { partialSig } = psbt.data.inputs[inputIndex];
22+
if (!partialSig) {
23+
return 0;
24+
}
25+
return partialSig.reduce((agg, p) => {
26+
const valid = psbt.validateSignaturesOfInputCommon(inputIndex, p.pubkey);
27+
return agg + (valid ? 1 : 0);
28+
}, 0);
29+
}
30+
31+
function getInputSignatures(psbt: utxolib.bitgo.UtxoPsbt): number[] {
32+
return psbt.data.inputs.map((_, i) => getInputSignaturesForInputIndex(psbt, i));
33+
}
34+
35+
export function explainPsbt(
36+
psbt: utxolib.bitgo.UtxoPsbt,
37+
descriptors: coreDescriptors.DescriptorMap
38+
): TransactionExplanation {
39+
const parsedTransaction = coreDescriptors.parse(psbt, descriptors, psbt.network);
40+
const { inputs, outputs } = parsedTransaction;
41+
const externalOutputs = outputs.filter((o) => o.scriptId === undefined);
42+
const changeOutputs = outputs.filter((o) => o.scriptId !== undefined);
43+
const fee = sumValues(inputs) - sumValues(outputs);
44+
const inputSignatures = getInputSignatures(psbt);
45+
return {
46+
inputSignatures,
47+
signatures: inputSignatures.reduce((a, b) => Math.min(a, b), Infinity),
48+
locktime: psbt.locktime,
49+
id: psbt.getUnsignedTx().getId(),
50+
outputs: externalOutputs.map((o) => toRecipient(o, psbt.network)),
51+
outputAmount: sumValues(externalOutputs).toString(),
52+
changeOutputs: changeOutputs.map((o) => toRecipient(o, psbt.network)),
53+
changeAmount: sumValues(changeOutputs).toString(),
54+
fee: fee.toString(),
55+
};
56+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { DescriptorMap } from '../../core/descriptor';
2+
export { explainPsbt } from './explainPsbt';

modules/abstract-utxo/test/core/descriptor/descriptor.utils.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Descriptor } from '@bitgo/wasm-miniscript';
22

33
import { DescriptorMap, PsbtParams } from '../../../src/core/descriptor';
4-
import { getKeyTriple } from '../key.utils';
4+
import { getKeyTriple, KeyTriple } from '../key.utils';
5+
import { BIP32Interface } from '@bitgo/utxo-lib';
56

67
export function getDefaultXPubs(seed?: string): string[] {
78
return getKeyTriple(seed).map((k) => k.neutered().toBase58());
@@ -21,15 +22,22 @@ export type DescriptorTemplate =
2122
*/
2223
| 'ShWsh2Of3CltvDrop';
2324

24-
function multi(m: number, n: number, keys: string[], path: string): string {
25+
function toXPub(k: BIP32Interface | string): string {
26+
if (typeof k === 'string') {
27+
return k;
28+
}
29+
return k.neutered().toBase58();
30+
}
31+
32+
function multi(m: number, n: number, keys: BIP32Interface[] | string[], path: string): string {
2533
if (n < m) {
2634
throw new Error(`Cannot create ${m} of ${n} multisig`);
2735
}
2836
if (keys.length < n) {
2937
throw new Error(`Not enough keys for ${m} of ${n} multisig: keys.length=${keys.length}`);
3038
}
3139
keys = keys.slice(0, n);
32-
return `multi(${m},${keys.map((k) => `${k}/${path}`).join(',')})`;
40+
return `multi(${m},${keys.map((k) => `${toXPub(k)}/${path}`).join(',')})`;
3341
}
3442

3543
export function getPsbtParams(t: DescriptorTemplate): Partial<PsbtParams> {
@@ -44,7 +52,7 @@ export function getPsbtParams(t: DescriptorTemplate): Partial<PsbtParams> {
4452

4553
export function getDescriptorString(
4654
template: DescriptorTemplate,
47-
keys: string[] = getDefaultXPubs(),
55+
keys: KeyTriple | string[] = getDefaultXPubs(),
4856
path = '0/*'
4957
): string {
5058
switch (template) {
@@ -62,13 +70,16 @@ export function getDescriptorString(
6270

6371
export function getDescriptor(
6472
template: DescriptorTemplate,
65-
keys: string[] = getDefaultXPubs(),
73+
keys: KeyTriple | string[] = getDefaultXPubs(),
6674
path = '0/*'
6775
): Descriptor {
6876
return Descriptor.fromString(getDescriptorString(template, keys, path), 'derivable');
6977
}
7078

71-
export function getDescriptorMap(template: DescriptorTemplate, keys: string[] = getDefaultXPubs()): DescriptorMap {
79+
export function getDescriptorMap(
80+
template: DescriptorTemplate,
81+
keys: KeyTriple | string[] = getDefaultXPubs()
82+
): DescriptorMap {
7283
return toDescriptorMap({
7384
external: getDescriptor(template, keys, '0/*').toString(),
7485
internal: getDescriptor(template, keys, '1/*').toString(),
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import assert from 'assert';
2+
3+
import { explainPsbt } from '../../../src/transaction/descriptor';
4+
import { mockPsbtDefaultWithDescriptorTemplate } from '../../core/descriptor/psbt/mock.utils';
5+
import { getDescriptorMap } from '../../core/descriptor/descriptor.utils';
6+
import { getFixture } from '../../core/fixtures.utils';
7+
import { getKeyTriple } from '../../core/key.utils';
8+
import { TransactionExplanation } from '../../../src';
9+
10+
async function assertEqualFixture(name: string, v: unknown) {
11+
assert.deepStrictEqual(v, await getFixture(__dirname + '/fixtures/' + name, v));
12+
}
13+
14+
function assertSignatureCount(expl: TransactionExplanation, signatures: number, inputSignatures: number[]) {
15+
assert.deepStrictEqual(expl.signatures, signatures);
16+
assert.deepStrictEqual(expl.inputSignatures, inputSignatures);
17+
}
18+
19+
describe('explainPsbt', function () {
20+
it('has expected values', async function () {
21+
const psbt = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3');
22+
const keys = getKeyTriple('a');
23+
const descriptorMap = getDescriptorMap('Wsh2Of3', keys);
24+
await assertEqualFixture('explainPsbt.a.json', explainPsbt(psbt, descriptorMap));
25+
psbt.signAllInputsHD(keys[0]);
26+
assertSignatureCount(explainPsbt(psbt, descriptorMap), 1, [1, 1]);
27+
psbt.signAllInputsHD(keys[1]);
28+
assertSignatureCount(explainPsbt(psbt, descriptorMap), 2, [2, 2]);
29+
});
30+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"inputSignatures": [
3+
0,
4+
0
5+
],
6+
"signatures": 0,
7+
"locktime": 0,
8+
"id": "b9b272a1f0407ea461aca2da115b3399311f424949653c37d5c0023102add158",
9+
"outputs": [
10+
{
11+
"address": "bc1qvn279lx29cg843u77p3c37npay7w4uc4xw5d92xxa92z8gd3lkuq4w8477",
12+
"amount": "400000"
13+
}
14+
],
15+
"outputAmount": "400000",
16+
"changeOutputs": [
17+
{
18+
"address": "bc1q2yau645jl7k577lmanqn9a0ulcgcqm0wrrmx09dppd3kcwguvyzqk86umj",
19+
"amount": "400000"
20+
}
21+
],
22+
"changeAmount": "400000",
23+
"fee": "1200000"
24+
}

0 commit comments

Comments
 (0)