Skip to content

Commit 4fbdd32

Browse files
feat(core): add descriptor-based PSBT test utils and fixtures
Adds utils to create and test PSBTs from descriptors with mock inputs and outputs. Issue: BTC-1845
1 parent bce40e1 commit 4fbdd32

File tree

2 files changed

+274
-0
lines changed

2 files changed

+274
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { BIP32Interface } from "@bitgo/utxo-lib";
2+
import { getKey } from "@bitgo/utxo-lib/dist/src/testutil";
3+
4+
import { DescriptorNode, formatNode } from "../js/ast";
5+
import { mockPsbtDefault } from "./psbtFromDescriptor.util";
6+
import { Descriptor } from "../js";
7+
import { toWrappedPsbt } from "./psbt.util";
8+
9+
function toKeyWithPath(k: BIP32Interface, path = "*"): string {
10+
return k.toBase58() + "/" + path;
11+
}
12+
13+
const external = getKey("external");
14+
const a = getKey("a");
15+
const b = getKey("b");
16+
const c = getKey("c");
17+
const keys = { external, a, b, c };
18+
function getKeyName(bipKey: BIP32Interface) {
19+
return Object.keys(keys).find(
20+
(k) => keys[k as keyof typeof keys] === bipKey,
21+
) as keyof typeof keys;
22+
}
23+
24+
function describeSignDescriptor(
25+
name: string,
26+
descriptor: DescriptorNode,
27+
signSeqs: BIP32Interface[][],
28+
) {
29+
describe(`psbt with descriptor ${name}`, function () {
30+
const psbt = mockPsbtDefault({
31+
descriptorSelf: Descriptor.fromString(formatNode(descriptor), "derivable"),
32+
descriptorOther: Descriptor.fromString(
33+
formatNode({ wpkh: toKeyWithPath(external) }),
34+
"derivable",
35+
),
36+
});
37+
38+
signSeqs.forEach((signSeq, i) => {
39+
it(`should sign ${signSeq.map((k) => getKeyName(k))}`, function () {
40+
const wrappedPsbt = toWrappedPsbt(psbt);
41+
signSeq.forEach((key) => {
42+
wrappedPsbt.signWithXprv(key.toBase58());
43+
});
44+
wrappedPsbt.finalize();
45+
});
46+
});
47+
});
48+
}
49+
50+
describeSignDescriptor(
51+
"Wsh2Of3",
52+
{
53+
wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] },
54+
},
55+
[
56+
[a, b],
57+
[b, a],
58+
],
59+
);
60+
61+
describeSignDescriptor(
62+
"Tr1Of3",
63+
{
64+
tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]],
65+
},
66+
[[a], [b], [c]],
67+
);
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import * as utxolib from "@bitgo/utxo-lib";
2+
import { toUtxoPsbt, toWrappedPsbt } from "./psbt.util";
3+
import { Descriptor } from "../js";
4+
5+
export function createScriptPubKeyFromDescriptor(descriptor: Descriptor, index?: number): Buffer {
6+
if (index === undefined) {
7+
return Buffer.from(descriptor.scriptPubkey());
8+
}
9+
return createScriptPubKeyFromDescriptor(descriptor.atDerivationIndex(index));
10+
}
11+
12+
export type Output = {
13+
script: Buffer;
14+
value: bigint;
15+
};
16+
17+
export type WithDescriptor<T> = T & {
18+
descriptor: Descriptor;
19+
};
20+
21+
export type PrevOutput = {
22+
hash: string;
23+
index: number;
24+
witnessUtxo: Output;
25+
};
26+
27+
export type DescriptorWalletOutput = PrevOutput & {
28+
descriptorName: string;
29+
descriptorIndex: number;
30+
};
31+
32+
export type DerivedDescriptorWalletOutput = WithDescriptor<PrevOutput>;
33+
34+
export function toDerivedDescriptorWalletOutput(
35+
output: DescriptorWalletOutput,
36+
descriptor: Descriptor,
37+
): DerivedDescriptorWalletOutput {
38+
const derivedDescriptor = descriptor.atDerivationIndex(output.descriptorIndex);
39+
const script = createScriptPubKeyFromDescriptor(derivedDescriptor);
40+
if (!script.equals(output.witnessUtxo.script)) {
41+
throw new Error(
42+
`Script mismatch: descriptor ${output.descriptorName} ${descriptor.toString()} script=${script}`,
43+
);
44+
}
45+
return {
46+
hash: output.hash,
47+
index: output.index,
48+
witnessUtxo: output.witnessUtxo,
49+
descriptor: descriptor.atDerivationIndex(output.descriptorIndex),
50+
};
51+
}
52+
53+
/**
54+
* Non-Final (Replaceable)
55+
* Reference: https://github.com/bitcoin/bitcoin/blob/v25.1/src/rpc/rawtransaction_util.cpp#L49
56+
* */
57+
export const MAX_BIP125_RBF_SEQUENCE = 0xffffffff - 2;
58+
59+
function updateInputsWithDescriptors(psbt: utxolib.bitgo.UtxoPsbt, descriptors: Descriptor[]) {
60+
if (psbt.txInputs.length !== descriptors.length) {
61+
throw new Error(
62+
`Input count mismatch (psbt=${psbt.txInputs.length}, descriptors=${descriptors.length})`,
63+
);
64+
}
65+
const wrappedPsbt = toWrappedPsbt(psbt);
66+
for (const [inputIndex, descriptor] of descriptors.entries()) {
67+
wrappedPsbt.updateInputWithDescriptor(inputIndex, descriptor);
68+
}
69+
const unwrappedPsbt = toUtxoPsbt(wrappedPsbt);
70+
for (const inputIndex in psbt.txInputs) {
71+
psbt.data.inputs[inputIndex] = unwrappedPsbt.data.inputs[inputIndex];
72+
}
73+
}
74+
75+
function updateOutputsWithDescriptors(
76+
psbt: utxolib.bitgo.UtxoPsbt,
77+
descriptors: WithOptDescriptor<Output>[],
78+
) {
79+
const wrappedPsbt = toWrappedPsbt(psbt);
80+
for (const [outputIndex, { descriptor }] of descriptors.entries()) {
81+
if (descriptor) {
82+
wrappedPsbt.updateOutputWithDescriptor(outputIndex, descriptor);
83+
}
84+
}
85+
const unwrappedPsbt = toUtxoPsbt(wrappedPsbt);
86+
for (const outputIndex in psbt.txOutputs) {
87+
psbt.data.outputs[outputIndex] = unwrappedPsbt.data.outputs[outputIndex];
88+
}
89+
}
90+
91+
type WithOptDescriptor<T> = T & { descriptor?: Descriptor };
92+
93+
export function createPsbt(
94+
params: PsbtParams,
95+
inputs: DerivedDescriptorWalletOutput[],
96+
outputs: WithOptDescriptor<Output>[],
97+
): utxolib.bitgo.UtxoPsbt {
98+
const psbt = utxolib.bitgo.UtxoPsbt.createPsbt({ network: params.network });
99+
psbt.setVersion(params.version ?? 2);
100+
psbt.setLocktime(params.locktime ?? 0);
101+
psbt.addInputs(
102+
inputs.map((i) => ({ ...i, sequence: params.sequence ?? MAX_BIP125_RBF_SEQUENCE })),
103+
);
104+
psbt.addOutputs(outputs);
105+
updateInputsWithDescriptors(
106+
psbt,
107+
inputs.map((i) => i.descriptor),
108+
);
109+
updateOutputsWithDescriptors(psbt, outputs);
110+
return psbt;
111+
}
112+
113+
type MockOutputIdParams = { hash?: string; vout?: number };
114+
115+
type BaseMockDescriptorOutputParams = {
116+
id?: MockOutputIdParams;
117+
index?: number;
118+
value?: bigint;
119+
};
120+
121+
function mockOutputId(id?: MockOutputIdParams): {
122+
hash: string;
123+
vout: number;
124+
} {
125+
const hash = id?.hash ?? Buffer.alloc(32, 1).toString("hex");
126+
const vout = id?.vout ?? 0;
127+
return { hash, vout };
128+
}
129+
130+
export function mockDerivedDescriptorWalletOutput(
131+
descriptor: Descriptor,
132+
outputParams: BaseMockDescriptorOutputParams = {},
133+
): DerivedDescriptorWalletOutput {
134+
const { value = BigInt(1e6) } = outputParams;
135+
const { hash, vout } = mockOutputId(outputParams.id);
136+
return {
137+
hash,
138+
index: vout,
139+
witnessUtxo: {
140+
script: createScriptPubKeyFromDescriptor(descriptor),
141+
value,
142+
},
143+
descriptor,
144+
};
145+
}
146+
147+
type MockInput = BaseMockDescriptorOutputParams & {
148+
index: number;
149+
descriptor: Descriptor;
150+
};
151+
152+
type MockOutput = {
153+
descriptor: Descriptor;
154+
index: number;
155+
value: bigint;
156+
external?: boolean;
157+
};
158+
159+
export function mockPsbt(
160+
inputs: MockInput[],
161+
outputs: MockOutput[],
162+
params: Partial<PsbtParams> = {},
163+
): utxolib.bitgo.UtxoPsbt {
164+
return createPsbt(
165+
{ ...params, network: params.network ?? utxolib.networks.bitcoin },
166+
inputs.map((i) =>
167+
mockDerivedDescriptorWalletOutput(i.descriptor.atDerivationIndex(i.index), i),
168+
),
169+
outputs.map((o) => {
170+
const derivedDescriptor = o.descriptor.atDerivationIndex(o.index);
171+
return {
172+
script: createScriptPubKeyFromDescriptor(derivedDescriptor),
173+
value: o.value,
174+
descriptor: o.external ? undefined : derivedDescriptor,
175+
};
176+
}),
177+
);
178+
}
179+
180+
export type PsbtParams = {
181+
network: utxolib.Network;
182+
version?: number;
183+
locktime?: number;
184+
sequence?: number;
185+
};
186+
187+
export function mockPsbtDefault({
188+
descriptorSelf,
189+
descriptorOther,
190+
params = {},
191+
}: {
192+
descriptorSelf: Descriptor;
193+
descriptorOther: Descriptor;
194+
params?: Partial<PsbtParams>;
195+
}): utxolib.bitgo.UtxoPsbt {
196+
return mockPsbt(
197+
[
198+
{ descriptor: descriptorSelf, index: 0 },
199+
{ descriptor: descriptorSelf, index: 1, id: { vout: 1 } },
200+
],
201+
[
202+
{ descriptor: descriptorOther, index: 0, value: BigInt(4e5), external: true },
203+
{ descriptor: descriptorSelf, index: 0, value: BigInt(4e5) },
204+
],
205+
params,
206+
);
207+
}

0 commit comments

Comments
 (0)