Skip to content

Commit 3735b84

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): port descriptor wallet functionality from utxo-core
This commit adds descriptor wallet functionality to wasm-utxo that was previously in @bitgo/utxo-core. It introduces a new `descriptorWallet` module with comprehensive support for descriptor-based wallet operations including: - Descriptor map handling and derivation - Output and address management - Virtual size calculation - PSBT creation and parsing - Pattern matching for descriptor ASTs - Script satisfaction testing The port removes the dependency on utxolib by using wasm-utxo's native classes directly, simplifying the code path for descriptor wallet operations. Issue: BTC-2866 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 9f39008 commit 3735b84

28 files changed

Lines changed: 2619 additions & 0 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* DescriptorMap type and utilities.
3+
* Moved from @bitgo/utxo-core.
4+
*/
5+
import { Descriptor } from "../index.js";
6+
7+
/** Map from descriptor name to descriptor (TypeScript Map) */
8+
export type DescriptorMap = Map<string, Descriptor>;
9+
10+
/** Convert an array of descriptor name-value pairs to a descriptor map */
11+
export function toDescriptorMap(
12+
descriptors: { name: string; value: Descriptor | string }[],
13+
): DescriptorMap {
14+
return new Map(
15+
descriptors.map((d) => [
16+
d.name,
17+
d.value instanceof Descriptor ? d.value : Descriptor.fromStringDetectType(d.value),
18+
]),
19+
);
20+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Descriptor output types and utilities.
3+
* Moved from @bitgo/utxo-core.
4+
*/
5+
import { Descriptor } from "../index.js";
6+
7+
import { getFixedOutputSum, MaxOutput, Output, PrevOutput } from "./Output.js";
8+
import { DescriptorMap } from "./DescriptorMap.js";
9+
import { getDescriptorAtIndexCheckScript } from "./derive.js";
10+
11+
export type WithDescriptor<T> = T & {
12+
descriptor: Descriptor;
13+
};
14+
15+
export type WithOptDescriptor<T> = T & {
16+
descriptor?: Descriptor;
17+
};
18+
19+
export function isInternalOutput<T extends object>(
20+
output: T | WithDescriptor<T>,
21+
): output is WithDescriptor<T> {
22+
return "descriptor" in output && output.descriptor !== undefined;
23+
}
24+
25+
export function isExternalOutput<T extends object>(output: T | WithDescriptor<T>): output is T {
26+
return !isInternalOutput(output);
27+
}
28+
29+
/**
30+
* @return the sum of the external outputs that are not 'max'
31+
* @param outputs
32+
*/
33+
export function getExternalFixedAmount(outputs: WithOptDescriptor<Output | MaxOutput>[]): bigint {
34+
return getFixedOutputSum(outputs.filter(isExternalOutput));
35+
}
36+
37+
export type DescriptorWalletOutput = PrevOutput & {
38+
descriptorName: string;
39+
descriptorIndex: number | undefined;
40+
};
41+
42+
export type DerivedDescriptorWalletOutput = WithDescriptor<PrevOutput>;
43+
44+
export function toDerivedDescriptorWalletOutput(
45+
output: DescriptorWalletOutput,
46+
descriptorMap: DescriptorMap,
47+
): DerivedDescriptorWalletOutput {
48+
const descriptor = descriptorMap.get(output.descriptorName);
49+
if (!descriptor) {
50+
throw new Error(`Descriptor not found: ${output.descriptorName}`);
51+
}
52+
if (!(descriptor instanceof Descriptor)) {
53+
throw new Error(`Expected Descriptor instance for ${output.descriptorName}`);
54+
}
55+
const descriptorAtIndex = getDescriptorAtIndexCheckScript(
56+
descriptor,
57+
output.descriptorIndex,
58+
output.witnessUtxo.script,
59+
output.descriptorName,
60+
);
61+
return {
62+
hash: output.hash,
63+
index: output.index,
64+
witnessUtxo: output.witnessUtxo,
65+
descriptor: descriptorAtIndex,
66+
};
67+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Migration Guide: utxo-core/descriptor to wasm-utxo/descriptorWallet
2+
3+
This module provides descriptor wallet functionality that was previously in `@bitgo/utxo-core`.
4+
5+
## Import Changes
6+
7+
### Before (utxo-core)
8+
9+
```typescript
10+
import {
11+
DescriptorMap,
12+
toDescriptorMap,
13+
findDescriptorForInput,
14+
createPsbt,
15+
parse,
16+
getDescriptorAtIndex,
17+
createScriptPubKeyFromDescriptor,
18+
getVirtualSize,
19+
} from "@bitgo/utxo-core/descriptor";
20+
```
21+
22+
### After (wasm-utxo)
23+
24+
```typescript
25+
import { descriptorWallet } from "@bitgo/wasm-utxo";
26+
27+
const {
28+
toDescriptorMap,
29+
findDescriptorForInput,
30+
createPsbt,
31+
parse,
32+
getDescriptorAtIndex,
33+
createScriptPubKeyFromDescriptor,
34+
getVirtualSize,
35+
} = descriptorWallet;
36+
```
37+
38+
## API Changes
39+
40+
### PSBT Creation
41+
42+
The `createPsbt` function returns a `wasm-utxo.Psbt` instead of `utxolib.bitgo.UtxoPsbt`.
43+
44+
```typescript
45+
// Before: Returns utxolib.bitgo.UtxoPsbt
46+
const psbt = createPsbt(params, inputs, outputs);
47+
48+
// After: Returns wasm-utxo Psbt
49+
const psbt = descriptorWallet.createPsbt(params, inputs, outputs);
50+
```
51+
52+
### Address Creation
53+
54+
The `createAddressFromDescriptor` function takes a `CoinName` instead of `utxolib.Network`:
55+
56+
```typescript
57+
// Before
58+
createAddressFromDescriptor(descriptor, index, utxolib.networks.bitcoin);
59+
60+
// After
61+
descriptorWallet.createAddressFromDescriptor(descriptor, index, "Bitcoin");
62+
```
63+
64+
### Signing
65+
66+
Use `signWithKey` from the descriptorWallet module:
67+
68+
```typescript
69+
// Before
70+
tx.signInputHD(vin, signerKeychain);
71+
72+
// After
73+
descriptorWallet.signWithKey(psbt, signerKeychain);
74+
```
75+
76+
## Not Ported
77+
78+
The following are intentionally **not** included in this migration:
79+
80+
- `fromFixedScriptWallet` - Converting fixed-script wallets to descriptors should remain in utxo-core or abstract-utxo
81+
82+
## Network Support
83+
84+
Descriptor wallets are currently only supported for Bitcoin mainnet and testnet.
85+
Altcoin descriptor wallets should continue using the fixed-script wallet approach.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Output types and utilities for descriptor wallets.
3+
* Moved from @bitgo/utxo-core.
4+
*/
5+
6+
export type Output<TValue = bigint> = {
7+
script: Uint8Array;
8+
value: TValue;
9+
};
10+
export type MaxOutput = Output<"max">;
11+
type ValueBigInt = { value: bigint };
12+
type ValueMax = { value: "max" };
13+
14+
/**
15+
* @return true if the output is a max output
16+
*/
17+
export function isMaxOutput<A extends ValueBigInt, B extends ValueMax>(output: A | B): output is B {
18+
return output.value === "max";
19+
}
20+
21+
/**
22+
* @return the max output if there is one
23+
* @throws if there are multiple max outputs
24+
*/
25+
export function getMaxOutput<A extends ValueBigInt, B extends ValueMax>(
26+
outputs: (A | B)[],
27+
): B | undefined {
28+
const max = outputs.filter(isMaxOutput<A, B>);
29+
if (max.length === 0) {
30+
return undefined;
31+
}
32+
if (max.length > 1) {
33+
throw new Error("Multiple max outputs");
34+
}
35+
return max[0];
36+
}
37+
38+
/**
39+
* @return the sum of the outputs
40+
*/
41+
export function getOutputSum(outputs: ValueBigInt[]): bigint {
42+
return outputs.reduce((sum, output) => sum + output.value, 0n);
43+
}
44+
45+
/**
46+
* @return the sum of the outputs that are not 'max'
47+
*/
48+
export function getFixedOutputSum(outputs: (ValueBigInt | ValueMax)[]): bigint {
49+
return getOutputSum(outputs.filter((o): o is Output => !isMaxOutput(o)));
50+
}
51+
52+
/**
53+
* @param outputs
54+
* @param params
55+
* @return the outputs with the 'max' output replaced with the max amount
56+
*/
57+
export function toFixedOutputs<A extends ValueBigInt, B extends ValueMax>(
58+
outputs: (A | B)[],
59+
params: { maxAmount: bigint },
60+
): A[] {
61+
// assert that there is at most one max output
62+
const maxOutput = getMaxOutput<A, B>(outputs);
63+
return outputs.map((output): A => {
64+
if (isMaxOutput(output)) {
65+
if (output !== maxOutput) {
66+
throw new Error("illegal state");
67+
}
68+
return { ...output, value: params.maxAmount };
69+
} else {
70+
return output;
71+
}
72+
});
73+
}
74+
75+
export type PrevOutput = {
76+
hash: string;
77+
index: number;
78+
witnessUtxo: Output;
79+
};
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Virtual size estimation for descriptor wallets.
3+
* Moved from @bitgo/utxo-core.
4+
*/
5+
import { Descriptor, Psbt } from "../index.js";
6+
import { Dimensions } from "../fixedScriptWallet/Dimensions.js";
7+
8+
import { DescriptorMap } from "./DescriptorMap.js";
9+
10+
// Transaction overhead for segwit transactions
11+
// 4 (version) + 1 (marker) + 1 (flag) + 1 (input count) + 1 (output count) + 4 (locktime) = 12
12+
// Weight units: 4*10 + 2 = 42, vsize = ceil(42/4) = 10.5
13+
const TX_SEGWIT_OVERHEAD_VSIZE = 10.5;
14+
15+
function getScriptPubKeyLength(descType: string): number {
16+
// See https://bitcoinops.org/en/tools/calc-size/
17+
switch (descType) {
18+
case "Wpkh":
19+
// https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wpkh
20+
return 22;
21+
case "Sh":
22+
case "ShWsh":
23+
case "ShWpkh":
24+
// https://github.com/bitcoin/bips/blob/master/bip-0016.mediawiki#specification
25+
return 23;
26+
case "Pkh":
27+
return 25;
28+
case "Wsh":
29+
case "Tr":
30+
// P2WSH: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh
31+
// P2TR: https://github.com/bitcoin/bips/blob/58ffd93812ff25e87d53d1f202fbb389fdfb85bb/bip-0341.mediawiki#script-validation-rules
32+
// > A Taproot output is a native SegWit output (see BIP141) with version number 1, and a 32-byte witness program.
33+
// 32 bytes for the hash, 1 byte for the version, 1 byte for the push opcode
34+
return 34;
35+
case "Bare":
36+
throw new Error("cannot determine scriptPubKey length for Bare descriptor");
37+
default:
38+
throw new Error("unexpected descriptor type " + descType);
39+
}
40+
}
41+
42+
function getInputVSizeForDescriptor(descriptor: Descriptor): number {
43+
// FIXME(BTC-1489): this can overestimate the size of the input significantly
44+
const maxWeight = descriptor.maxWeightToSatisfy();
45+
const maxVSize = Math.ceil(maxWeight / 4);
46+
const sizeOpPushdata1 = 1;
47+
const sizeOpPushdata2 = 2;
48+
return (
49+
// inputId
50+
32 +
51+
// vOut
52+
4 +
53+
// nSequence
54+
4 +
55+
// script overhead
56+
(maxVSize < 255 ? sizeOpPushdata1 : sizeOpPushdata2) +
57+
// script
58+
maxVSize
59+
);
60+
}
61+
62+
export function getInputVSizesForDescriptors(descriptors: DescriptorMap): Record<string, number> {
63+
return Object.fromEntries(
64+
Array.from(descriptors.entries()).map(([name, d]) => {
65+
return [name, getInputVSizeForDescriptor(d)];
66+
}),
67+
);
68+
}
69+
70+
export function getChangeOutputVSizesForDescriptor(d: Descriptor): {
71+
inputVSize: number;
72+
outputVSize: number;
73+
} {
74+
return {
75+
inputVSize: getInputVSizeForDescriptor(d),
76+
outputVSize: getScriptPubKeyLength(d.descType() as string),
77+
};
78+
}
79+
80+
type InputWithDescriptorName = { descriptorName: string };
81+
type OutputWithScript = { script: Uint8Array };
82+
83+
type Tx<TInput> = {
84+
inputs: TInput[];
85+
outputs: OutputWithScript[];
86+
};
87+
88+
export function getVirtualSize(tx: Tx<Descriptor>): number;
89+
export function getVirtualSize(tx: Tx<InputWithDescriptorName>, descriptors: DescriptorMap): number;
90+
export function getVirtualSize(
91+
tx: Tx<Descriptor> | Tx<InputWithDescriptorName>,
92+
descriptorMap?: DescriptorMap,
93+
): number {
94+
const lookup = descriptorMap ? getInputVSizesForDescriptors(descriptorMap) : undefined;
95+
const inputVSize = tx.inputs.reduce((sum, input) => {
96+
if (input instanceof Descriptor) {
97+
return sum + getInputVSizeForDescriptor(input);
98+
}
99+
if ("descriptorName" in input) {
100+
if (!lookup) {
101+
throw new Error("missing descriptorMap");
102+
}
103+
const vsize = lookup[input.descriptorName];
104+
if (!vsize) {
105+
throw new Error(`Could not find descriptor ${input.descriptorName}`);
106+
}
107+
return sum + vsize;
108+
}
109+
throw new Error("unexpected input");
110+
}, 0);
111+
112+
const outputVSize = tx.outputs.reduce((sum, o) => {
113+
// Use the Dimensions class to calculate output vsize
114+
return sum + Dimensions.fromOutput({ length: o.script.length }).getOutputVSize();
115+
}, 0);
116+
117+
// we will just assume that we have at least one segwit input
118+
return inputVSize + outputVSize + TX_SEGWIT_OVERHEAD_VSIZE;
119+
}
120+
121+
export function getVirtualSizeEstimateForPsbt(psbt: Psbt, descriptorMap: DescriptorMap): number {
122+
const inputCount = psbt.inputCount();
123+
const outputCount = psbt.outputCount();
124+
125+
// Calculate a rough estimate based on descriptor map
126+
// For a more accurate estimation, we would need to deserialize the PSBT data
127+
let totalInputVSize = 0;
128+
for (const descriptor of descriptorMap.values()) {
129+
totalInputVSize += getInputVSizeForDescriptor(descriptor);
130+
}
131+
132+
// Average input size * input count
133+
const avgInputVSize = descriptorMap.size > 0 ? totalInputVSize / descriptorMap.size : 100; // fallback
134+
135+
const inputVSize = avgInputVSize * inputCount;
136+
137+
// Assume P2WPKH outputs (34 bytes each) as a reasonable default
138+
const outputVSize = outputCount * Dimensions.fromOutput({ length: 34 }).getOutputVSize();
139+
140+
return inputVSize + outputVSize + TX_SEGWIT_OVERHEAD_VSIZE;
141+
}

0 commit comments

Comments
 (0)