diff --git a/packages/wasm-utxo/eslint.config.js b/packages/wasm-utxo/eslint.config.js index 44c73c2..c0492ed 100644 --- a/packages/wasm-utxo/eslint.config.js +++ b/packages/wasm-utxo/eslint.config.js @@ -7,7 +7,7 @@ export default tseslint.config( { languageOptions: { parserOptions: { - projectService: true, + project: ["./tsconfig.json", "./tsconfig.test.json"], tsconfigRootDir: import.meta.dirname, }, }, @@ -25,4 +25,29 @@ export default tseslint.config( "*.config.js", ], }, + // Ban Node.js globals in production code + { + files: ["js/**/*.ts"], + rules: { + "no-restricted-globals": [ + "error", + { + name: "Buffer", + message: "Use Uint8Array instead of Buffer for ESM compatibility.", + }, + { + name: "process", + message: "Avoid Node.js process global for ESM compatibility.", + }, + { + name: "__dirname", + message: "Use import.meta.url instead of __dirname for ESM.", + }, + { + name: "__filename", + message: "Use import.meta.url instead of __filename for ESM.", + }, + ], + }, + }, ); diff --git a/packages/wasm-utxo/js/descriptorWallet/DescriptorMap.ts b/packages/wasm-utxo/js/descriptorWallet/DescriptorMap.ts new file mode 100644 index 0000000..3af3fd8 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/DescriptorMap.ts @@ -0,0 +1,20 @@ +/** + * DescriptorMap type and utilities. + * Moved from @bitgo/utxo-core. + */ +import { Descriptor } from "../index.js"; + +/** Map from descriptor name to descriptor (TypeScript Map) */ +export type DescriptorMap = Map; + +/** Convert an array of descriptor name-value pairs to a descriptor map */ +export function toDescriptorMap( + descriptors: { name: string; value: Descriptor | string }[], +): DescriptorMap { + return new Map( + descriptors.map((d) => [ + d.name, + d.value instanceof Descriptor ? d.value : Descriptor.fromStringDetectType(d.value), + ]), + ); +} diff --git a/packages/wasm-utxo/js/descriptorWallet/DescriptorOutput.ts b/packages/wasm-utxo/js/descriptorWallet/DescriptorOutput.ts new file mode 100644 index 0000000..abb2b62 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/DescriptorOutput.ts @@ -0,0 +1,67 @@ +/** + * Descriptor output types and utilities. + * Moved from @bitgo/utxo-core. + */ +import { Descriptor } from "../index.js"; + +import { getFixedOutputSum, MaxOutput, Output, PrevOutput } from "./Output.js"; +import { DescriptorMap } from "./DescriptorMap.js"; +import { getDescriptorAtIndexCheckScript } from "./derive.js"; + +export type WithDescriptor = T & { + descriptor: Descriptor; +}; + +export type WithOptDescriptor = T & { + descriptor?: Descriptor; +}; + +export function isInternalOutput( + output: T | WithDescriptor, +): output is WithDescriptor { + return "descriptor" in output && output.descriptor !== undefined; +} + +export function isExternalOutput(output: T | WithDescriptor): output is T { + return !isInternalOutput(output); +} + +/** + * @return the sum of the external outputs that are not 'max' + * @param outputs + */ +export function getExternalFixedAmount(outputs: WithOptDescriptor[]): bigint { + return getFixedOutputSum(outputs.filter(isExternalOutput)); +} + +export type DescriptorWalletOutput = PrevOutput & { + descriptorName: string; + descriptorIndex: number | undefined; +}; + +export type DerivedDescriptorWalletOutput = WithDescriptor; + +export function toDerivedDescriptorWalletOutput( + output: DescriptorWalletOutput, + descriptorMap: DescriptorMap, +): DerivedDescriptorWalletOutput { + const descriptor = descriptorMap.get(output.descriptorName); + if (!descriptor) { + throw new Error(`Descriptor not found: ${output.descriptorName}`); + } + if (!(descriptor instanceof Descriptor)) { + throw new Error(`Expected Descriptor instance for ${output.descriptorName}`); + } + const descriptorAtIndex = getDescriptorAtIndexCheckScript( + descriptor, + output.descriptorIndex, + output.witnessUtxo.script, + output.descriptorName, + ); + return { + hash: output.hash, + index: output.index, + witnessUtxo: output.witnessUtxo, + descriptor: descriptorAtIndex, + }; +} diff --git a/packages/wasm-utxo/js/descriptorWallet/MIGRATION.md b/packages/wasm-utxo/js/descriptorWallet/MIGRATION.md new file mode 100644 index 0000000..4756a79 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/MIGRATION.md @@ -0,0 +1,85 @@ +# Migration Guide: utxo-core/descriptor to wasm-utxo/descriptorWallet + +This module provides descriptor wallet functionality that was previously in `@bitgo/utxo-core`. + +## Import Changes + +### Before (utxo-core) + +```typescript +import { + DescriptorMap, + toDescriptorMap, + findDescriptorForInput, + createPsbt, + parse, + getDescriptorAtIndex, + createScriptPubKeyFromDescriptor, + getVirtualSize, +} from "@bitgo/utxo-core/descriptor"; +``` + +### After (wasm-utxo) + +```typescript +import { descriptorWallet } from "@bitgo/wasm-utxo"; + +const { + toDescriptorMap, + findDescriptorForInput, + createPsbt, + parse, + getDescriptorAtIndex, + createScriptPubKeyFromDescriptor, + getVirtualSize, +} = descriptorWallet; +``` + +## API Changes + +### PSBT Creation + +The `createPsbt` function returns a `wasm-utxo.Psbt` instead of `utxolib.bitgo.UtxoPsbt`. + +```typescript +// Before: Returns utxolib.bitgo.UtxoPsbt +const psbt = createPsbt(params, inputs, outputs); + +// After: Returns wasm-utxo Psbt +const psbt = descriptorWallet.createPsbt(params, inputs, outputs); +``` + +### Address Creation + +The `createAddressFromDescriptor` function takes a `CoinName` instead of `utxolib.Network`: + +```typescript +// Before +createAddressFromDescriptor(descriptor, index, utxolib.networks.bitcoin); + +// After +descriptorWallet.createAddressFromDescriptor(descriptor, index, "Bitcoin"); +``` + +### Signing + +Use `signWithKey` from the descriptorWallet module: + +```typescript +// Before +tx.signInputHD(vin, signerKeychain); + +// After +descriptorWallet.signWithKey(psbt, signerKeychain); +``` + +## Not Ported + +The following are intentionally **not** included in this migration: + +- `fromFixedScriptWallet` - Converting fixed-script wallets to descriptors should remain in utxo-core or abstract-utxo + +## Network Support + +Descriptor wallets are currently only supported for Bitcoin mainnet and testnet. +Altcoin descriptor wallets should continue using the fixed-script wallet approach. diff --git a/packages/wasm-utxo/js/descriptorWallet/Output.ts b/packages/wasm-utxo/js/descriptorWallet/Output.ts new file mode 100644 index 0000000..2ea61d0 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/Output.ts @@ -0,0 +1,79 @@ +/** + * Output types and utilities for descriptor wallets. + * Moved from @bitgo/utxo-core. + */ + +export type Output = { + script: Uint8Array; + value: TValue; +}; +export type MaxOutput = Output<"max">; +type ValueBigInt = { value: bigint }; +type ValueMax = { value: "max" }; + +/** + * @return true if the output is a max output + */ +export function isMaxOutput(output: A | B): output is B { + return output.value === "max"; +} + +/** + * @return the max output if there is one + * @throws if there are multiple max outputs + */ +export function getMaxOutput( + outputs: (A | B)[], +): B | undefined { + const max = outputs.filter(isMaxOutput); + if (max.length === 0) { + return undefined; + } + if (max.length > 1) { + throw new Error("Multiple max outputs"); + } + return max[0]; +} + +/** + * @return the sum of the outputs + */ +export function getOutputSum(outputs: ValueBigInt[]): bigint { + return outputs.reduce((sum, output) => sum + output.value, 0n); +} + +/** + * @return the sum of the outputs that are not 'max' + */ +export function getFixedOutputSum(outputs: (ValueBigInt | ValueMax)[]): bigint { + return getOutputSum(outputs.filter((o): o is Output => !isMaxOutput(o))); +} + +/** + * @param outputs + * @param params + * @return the outputs with the 'max' output replaced with the max amount + */ +export function toFixedOutputs( + outputs: (A | B)[], + params: { maxAmount: bigint }, +): A[] { + // assert that there is at most one max output + const maxOutput = getMaxOutput(outputs); + return outputs.map((output): A => { + if (isMaxOutput(output)) { + if (output !== maxOutput) { + throw new Error("illegal state"); + } + return { ...output, value: params.maxAmount }; + } else { + return output; + } + }); +} + +export type PrevOutput = { + hash: string; + index: number; + witnessUtxo: Output; +}; diff --git a/packages/wasm-utxo/js/descriptorWallet/VirtualSize.ts b/packages/wasm-utxo/js/descriptorWallet/VirtualSize.ts new file mode 100644 index 0000000..c524717 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/VirtualSize.ts @@ -0,0 +1,141 @@ +/** + * Virtual size estimation for descriptor wallets. + * Moved from @bitgo/utxo-core. + */ +import { Descriptor, Psbt } from "../index.js"; +import { Dimensions } from "../fixedScriptWallet/Dimensions.js"; + +import { DescriptorMap } from "./DescriptorMap.js"; + +// Transaction overhead for segwit transactions +// 4 (version) + 1 (marker) + 1 (flag) + 1 (input count) + 1 (output count) + 4 (locktime) = 12 +// Weight units: 4*10 + 2 = 42, vsize = ceil(42/4) = 10.5 +const TX_SEGWIT_OVERHEAD_VSIZE = 10.5; + +function getScriptPubKeyLength(descType: string): number { + // See https://bitcoinops.org/en/tools/calc-size/ + switch (descType) { + case "Wpkh": + // https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wpkh + return 22; + case "Sh": + case "ShWsh": + case "ShWpkh": + // https://github.com/bitcoin/bips/blob/master/bip-0016.mediawiki#specification + return 23; + case "Pkh": + return 25; + case "Wsh": + case "Tr": + // P2WSH: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh + // P2TR: https://github.com/bitcoin/bips/blob/58ffd93812ff25e87d53d1f202fbb389fdfb85bb/bip-0341.mediawiki#script-validation-rules + // > A Taproot output is a native SegWit output (see BIP141) with version number 1, and a 32-byte witness program. + // 32 bytes for the hash, 1 byte for the version, 1 byte for the push opcode + return 34; + case "Bare": + throw new Error("cannot determine scriptPubKey length for Bare descriptor"); + default: + throw new Error("unexpected descriptor type " + descType); + } +} + +function getInputVSizeForDescriptor(descriptor: Descriptor): number { + // FIXME(BTC-1489): this can overestimate the size of the input significantly + const maxWeight = descriptor.maxWeightToSatisfy(); + const maxVSize = Math.ceil(maxWeight / 4); + const sizeOpPushdata1 = 1; + const sizeOpPushdata2 = 2; + return ( + // inputId + 32 + + // vOut + 4 + + // nSequence + 4 + + // script overhead + (maxVSize < 255 ? sizeOpPushdata1 : sizeOpPushdata2) + + // script + maxVSize + ); +} + +export function getInputVSizesForDescriptors(descriptors: DescriptorMap): Record { + return Object.fromEntries( + Array.from(descriptors.entries()).map(([name, d]) => { + return [name, getInputVSizeForDescriptor(d)]; + }), + ); +} + +export function getChangeOutputVSizesForDescriptor(d: Descriptor): { + inputVSize: number; + outputVSize: number; +} { + return { + inputVSize: getInputVSizeForDescriptor(d), + outputVSize: getScriptPubKeyLength(d.descType() as string), + }; +} + +type InputWithDescriptorName = { descriptorName: string }; +type OutputWithScript = { script: Uint8Array }; + +type Tx = { + inputs: TInput[]; + outputs: OutputWithScript[]; +}; + +export function getVirtualSize(tx: Tx): number; +export function getVirtualSize(tx: Tx, descriptors: DescriptorMap): number; +export function getVirtualSize( + tx: Tx | Tx, + descriptorMap?: DescriptorMap, +): number { + const lookup = descriptorMap ? getInputVSizesForDescriptors(descriptorMap) : undefined; + const inputVSize = tx.inputs.reduce((sum, input) => { + if (input instanceof Descriptor) { + return sum + getInputVSizeForDescriptor(input); + } + if ("descriptorName" in input) { + if (!lookup) { + throw new Error("missing descriptorMap"); + } + const vsize = lookup[input.descriptorName]; + if (!vsize) { + throw new Error(`Could not find descriptor ${input.descriptorName}`); + } + return sum + vsize; + } + throw new Error("unexpected input"); + }, 0); + + const outputVSize = tx.outputs.reduce((sum, o) => { + // Use the Dimensions class to calculate output vsize + return sum + Dimensions.fromOutput({ length: o.script.length }).getOutputVSize(); + }, 0); + + // we will just assume that we have at least one segwit input + return inputVSize + outputVSize + TX_SEGWIT_OVERHEAD_VSIZE; +} + +export function getVirtualSizeEstimateForPsbt(psbt: Psbt, descriptorMap: DescriptorMap): number { + const inputCount = psbt.inputCount(); + const outputCount = psbt.outputCount(); + + // Calculate a rough estimate based on descriptor map + // For a more accurate estimation, we would need to deserialize the PSBT data + let totalInputVSize = 0; + for (const descriptor of descriptorMap.values()) { + totalInputVSize += getInputVSizeForDescriptor(descriptor); + } + + // Average input size * input count + const avgInputVSize = descriptorMap.size > 0 ? totalInputVSize / descriptorMap.size : 100; // fallback + + const inputVSize = avgInputVSize * inputCount; + + // Assume P2WPKH outputs (34 bytes each) as a reasonable default + const outputVSize = outputCount * Dimensions.fromOutput({ length: 34 }).getOutputVSize(); + + return inputVSize + outputVSize + TX_SEGWIT_OVERHEAD_VSIZE; +} diff --git a/packages/wasm-utxo/js/descriptorWallet/address.ts b/packages/wasm-utxo/js/descriptorWallet/address.ts new file mode 100644 index 0000000..6f26c34 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/address.ts @@ -0,0 +1,25 @@ +/** + * Descriptor address generation utilities. + * Moved from @bitgo/utxo-core. + */ +import { Descriptor } from "../index.js"; +import { fromOutputScriptWithCoin } from "../address.js"; +import type { CoinName } from "../coinName.js"; + +export function createScriptPubKeyFromDescriptor( + descriptor: Descriptor, + index: number | undefined, +): Uint8Array { + if (index === undefined) { + return descriptor.scriptPubkey(); + } + return createScriptPubKeyFromDescriptor(descriptor.atDerivationIndex(index), undefined); +} + +export function createAddressFromDescriptor( + descriptor: Descriptor, + index: number | undefined, + coin: CoinName, +): string { + return fromOutputScriptWithCoin(createScriptPubKeyFromDescriptor(descriptor, index), coin); +} diff --git a/packages/wasm-utxo/js/descriptorWallet/derive.ts b/packages/wasm-utxo/js/descriptorWallet/derive.ts new file mode 100644 index 0000000..fb6836d --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/derive.ts @@ -0,0 +1,53 @@ +/** + * Descriptor derivation utilities. + * Moved from @bitgo/utxo-core. + */ +import { Descriptor } from "../index.js"; + +/** + * Get a descriptor at a specific derivation index. + * For wildcard descriptors (containing '*'), the index is required and used for derivation. + * For definite descriptors (not containing '*'), no index should be provided. + * @param descriptor - The descriptor to derive from + * @param index - The derivation index for wildcard descriptors + * @returns A new descriptor at the specified index for wildcard descriptors, or the original descriptor for definite ones + * @throws {Error} If index is undefined for a wildcard descriptor or if index is provided for a definite descriptor + */ +export function getDescriptorAtIndex( + descriptor: Descriptor, + index: number | undefined, +): Descriptor { + if (!(descriptor instanceof Descriptor)) { + throw new Error("Expected Descriptor instance"); + } + Descriptor.fromString(descriptor.toString(), "derivable"); + descriptor = Descriptor.fromStringDetectType(descriptor.toString()); + if (descriptor.hasWildcard()) { + if (index === undefined) { + throw new Error("Derivable descriptor requires an index"); + } + return descriptor.atDerivationIndex(index); + } else { + if (index !== undefined) { + throw new Error("Definite descriptor cannot be derived with index"); + } + return descriptor; + } +} + +export function getDescriptorAtIndexCheckScript( + descriptor: Descriptor, + index: number | undefined, + script: Uint8Array, + descriptorString = descriptor.toString(), +): Descriptor { + if (!(descriptor instanceof Descriptor)) { + throw new Error("Expected Descriptor instance"); + } + const descriptorAtIndex = getDescriptorAtIndex(descriptor, index); + const expectedScript = descriptorAtIndex.scriptPubkey(); + if (script.length !== expectedScript.length || !script.every((b, i) => b === expectedScript[i])) { + throw new Error(`Script mismatch: descriptor ${descriptorString}`); + } + return descriptorAtIndex; +} diff --git a/packages/wasm-utxo/js/descriptorWallet/index.ts b/packages/wasm-utxo/js/descriptorWallet/index.ts new file mode 100644 index 0000000..b9f4d66 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/index.ts @@ -0,0 +1,49 @@ +/** + * Descriptor wallet utilities. + * Moved from @bitgo/utxo-core. + * + * This module provides descriptor wallet functionality using wasm-utxo directly, + * eliminating the need for utxo-lib in descriptor wallet code paths. + * + * ## Migration from utxo-core + * + * If you were previously importing from `@bitgo/utxo-core/descriptor`, you should + * now import from `@bitgo/wasm-utxo`: + * + * ```typescript + * // Before (deprecated) + * import { DescriptorMap, createPsbt } from '@bitgo/utxo-core/descriptor'; + * + * // After + * import { descriptorWallet } from '@bitgo/wasm-utxo'; + * const { DescriptorMap, createPsbt } = descriptorWallet; + * ``` + * + * @see {@link ./MIGRATION.md} for detailed migration instructions. + * + * @remarks + * The following imports from `@bitgo/utxo-core/descriptor` are now deprecated + * and should use this module instead: + * - DescriptorMap, toDescriptorMap + * - createPsbt, parse, findDescriptorForInput, findDescriptorForOutput + * - getDescriptorAtIndex, getDescriptorAtIndexCheckScript + * - createScriptPubKeyFromDescriptor, createAddressFromDescriptor + * - getVirtualSize, getVirtualSizeEstimateForPsbt + * - signWithKey, getNewSignatureCount + * - assertSatisfiable, getRequiredLocktime + * - PatternMatcher + */ + +// Core types and utilities +export * from "./Output.js"; +export * from "./DescriptorMap.js"; +export * from "./derive.js"; +export * from "./DescriptorOutput.js"; +export * from "./address.js"; +export * from "./VirtualSize.js"; + +// PSBT utilities +export * from "./psbt/index.js"; + +// Pattern matching +export * from "./parse/PatternMatcher.js"; diff --git a/packages/wasm-utxo/js/descriptorWallet/parse/PatternMatcher.ts b/packages/wasm-utxo/js/descriptorWallet/parse/PatternMatcher.ts new file mode 100644 index 0000000..3338a0b --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/parse/PatternMatcher.ts @@ -0,0 +1,97 @@ +/** + * Pattern matching utilities for descriptor ASTs. + * Moved from @bitgo/utxo-core. + */ + +// Pattern matching types +export type PatternVar = { $var: string }; +export type Pattern = + | PatternVar + | string + | number + | { [key: string]: Pattern | Pattern[] } + | Pattern[]; + +export type ExtractedVars = Record; + +export class PatternMatcher { + match(node: unknown, pattern: Pattern): ExtractedVars | null { + const vars: ExtractedVars = {}; + return this.matchNode(node, pattern, vars) ? vars : null; + } + + private matchNode(node: unknown, pattern: Pattern, vars: ExtractedVars): boolean { + // Variable placeholder + if (this.isPatternVar(pattern)) { + const varName = pattern.$var; + if (varName in vars) { + return this.deepEqual(vars[varName], node); + } + vars[varName] = node; + return true; + } + + // Primitive values + if (typeof node !== typeof pattern) return false; + if (typeof node === "string" || typeof node === "number") { + return node === pattern; + } + + // Arrays + if (Array.isArray(node) && Array.isArray(pattern)) { + return ( + node.length === pattern.length && + node.every((item, i) => this.matchNode(item, pattern[i], vars)) + ); + } + + // Objects + if ( + typeof node === "object" && + typeof pattern === "object" && + node !== null && + pattern !== null + ) { + const nodeKeys = Object.keys(node); + const patternKeys = Object.keys(pattern); + + return ( + nodeKeys.length === patternKeys.length && + nodeKeys.every( + (key) => + patternKeys.includes(key) && + this.matchNode( + (node as Record)[key], + (pattern as Record)[key], + vars, + ), + ) + ); + } + + return false; + } + + private isPatternVar(value: unknown): value is PatternVar { + return value !== null && typeof value === "object" && "$var" in value; + } + + private deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => this.deepEqual(item, b[i])); + } + if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + return ( + keysA.length === keysB.length && + keysA.every((key) => + this.deepEqual((a as Record)[key], (b as Record)[key]), + ) + ); + } + return false; + } +} diff --git a/packages/wasm-utxo/js/descriptorWallet/psbt/assertSatisfiable.ts b/packages/wasm-utxo/js/descriptorWallet/psbt/assertSatisfiable.ts new file mode 100644 index 0000000..3d78892 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/psbt/assertSatisfiable.ts @@ -0,0 +1,81 @@ +/** + * Helpers for testing satisfiability of descriptors in PSBTs. + * + * They are mostly a debugging aid - if an input cannot be satisified, the `finalizePsbt()` method will fail, but + * the error message is pretty vague. + * + * The methods here have the goal of catching certain cases earlier and with a better error message. + * + * The goal is not an exhaustive check, but to catch common mistakes. + * + * Moved from @bitgo/utxo-core. + */ +import { Descriptor, Psbt } from "../../index.js"; + +export const FINAL_SEQUENCE = 0xffffffff; + +/** + * Get the required locktime for a descriptor. + * @param descriptor + */ +export function getRequiredLocktime(descriptor: unknown): number | undefined { + if (descriptor instanceof Descriptor) { + return getRequiredLocktime(descriptor.node()); + } + if (typeof descriptor !== "object" || descriptor === null) { + return undefined; + } + if ("Wsh" in descriptor) { + return getRequiredLocktime((descriptor as { Wsh: unknown }).Wsh); + } + if ("Sh" in descriptor) { + return getRequiredLocktime((descriptor as { Sh: unknown }).Sh); + } + if ("Ms" in descriptor) { + return getRequiredLocktime((descriptor as { Ms: unknown }).Ms); + } + if ("AndV" in descriptor) { + const andV = (descriptor as { AndV: unknown }).AndV; + if (!Array.isArray(andV)) { + throw new Error("Expected an array"); + } + if (andV.length !== 2) { + throw new Error("Expected exactly two elements"); + } + const [a, b] = andV as [unknown, unknown]; + return getRequiredLocktime(a) ?? getRequiredLocktime(b); + } + if ("Drop" in descriptor) { + return getRequiredLocktime((descriptor as { Drop: unknown }).Drop); + } + if ("Verify" in descriptor) { + return getRequiredLocktime((descriptor as { Verify: unknown }).Verify); + } + if ("After" in descriptor) { + const after = (descriptor as { After: unknown }).After; + if (typeof after === "object" && after !== null) { + if ( + "absLockTime" in after && + typeof (after as { absLockTime: unknown }).absLockTime === "number" + ) { + return (after as { absLockTime: number }).absLockTime; + } + } + } + return undefined; +} + +export function assertSatisfiable(psbt: Psbt, _inputIndex: number, descriptor: Descriptor): void { + // If the descriptor requires a locktime, the input must have a non-final sequence number + const requiredLocktime = getRequiredLocktime(descriptor); + if (requiredLocktime !== undefined) { + // Note: We cannot easily check sequence from wasm-utxo Psbt without additional methods + // For now, we just check the locktime + const psbtLocktime = psbt.lockTime(); + if (psbtLocktime !== requiredLocktime) { + throw new Error( + `psbt locktime (${psbtLocktime}) does not match required locktime (${requiredLocktime})`, + ); + } + } +} diff --git a/packages/wasm-utxo/js/descriptorWallet/psbt/createPsbt.ts b/packages/wasm-utxo/js/descriptorWallet/psbt/createPsbt.ts new file mode 100644 index 0000000..d69683f --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/psbt/createPsbt.ts @@ -0,0 +1,121 @@ +/** + * PSBT creation for descriptor wallets. + * Moved from @bitgo/utxo-core. + * + * This version uses wasm-utxo Psbt directly without the wrap/unwrap pattern. + */ +import { Miniscript, Psbt } from "../../index.js"; + +/** Taproot leaf script (inlined from bip174) */ +export type TapLeafScript = { + leafVersion: number; + script: Uint8Array; + controlBlock: Uint8Array; +}; + +import { DerivedDescriptorWalletOutput, WithOptDescriptor } from "../DescriptorOutput.js"; +import { Output } from "../Output.js"; + +import { assertSatisfiable } from "./assertSatisfiable.js"; + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Non-Final (Replaceable) + * Reference: https://github.com/bitcoin/bitcoin/blob/v25.1/src/rpc/rawtransaction_util.cpp#L49 + * */ +export const MAX_BIP125_RBF_SEQUENCE = 0xffffffff - 2; + +export function findTapLeafScript( + input: TapLeafScript[], + script: Uint8Array | Miniscript, +): TapLeafScript { + if (!(script instanceof Uint8Array)) { + script = script.encode(); + } + const scriptBytes = script; + const matches = input.filter((leaf) => { + return bytesEqual(leaf.script, scriptBytes); + }); + if (matches.length === 0) { + throw new Error(`No tapLeafScript found for script: ${toHex(scriptBytes)}`); + } + if (matches.length > 1) { + throw new Error(`Multiple tapLeafScripts found for script: ${toHex(scriptBytes)}`); + } + return matches[0]; +} + +export type PsbtParams = { + version?: number; + locktime?: number; + sequence?: number; +}; + +export type DerivedDescriptorTransactionInput = DerivedDescriptorWalletOutput & { + selectTapLeafScript?: Miniscript; + sequence?: number; +}; + +/** + * Create a PSBT for descriptor wallet transactions. + * + * This function uses wasm-utxo Psbt directly without any wrap/unwrap conversions. + * + * @param params - PSBT parameters (version, locktime, sequence) + * @param inputs - Descriptor wallet inputs + * @param outputs - Outputs with optional descriptors for change + * @returns A wasm-utxo Psbt instance + */ +export function createPsbt( + params: PsbtParams, + inputs: DerivedDescriptorTransactionInput[], + outputs: WithOptDescriptor[], +): Psbt { + const psbt = new Psbt(params.version ?? 2, params.locktime ?? 0); + + // Add inputs + for (const input of inputs) { + const sequence = input.sequence ?? params.sequence ?? MAX_BIP125_RBF_SEQUENCE; + + psbt.addInput( + input.hash, + input.index, + input.witnessUtxo.value, + input.witnessUtxo.script, + sequence, + ); + } + + // Add outputs + for (const output of outputs) { + psbt.addOutput(output.script, output.value); + } + + // Update inputs with descriptor metadata + for (const [inputIndex, input] of inputs.entries()) { + assertSatisfiable(psbt, inputIndex, input.descriptor); + psbt.updateInputWithDescriptor(inputIndex, input.descriptor); + } + + // Update outputs with descriptor metadata (for change outputs) + for (const [outputIndex, output] of outputs.entries()) { + if (output.descriptor) { + psbt.updateOutputWithDescriptor(outputIndex, output.descriptor); + } + } + + return psbt; +} diff --git a/packages/wasm-utxo/js/descriptorWallet/psbt/findDescriptors.ts b/packages/wasm-utxo/js/descriptorWallet/psbt/findDescriptors.ts new file mode 100644 index 0000000..566d322 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/psbt/findDescriptors.ts @@ -0,0 +1,173 @@ +/** + * Utilities for mapping back from PSBT inputs to descriptors. + * + * This is a somewhat brute-force attempt that relies on the `bip32Derivation` field to be set. + * + * It will probably only work correctly if all xpubs in the descriptor are derivable. + * + * We should take a look at a more robust and standard approach like this: https://github.com/bitcoin/bips/pull/1548 + * + * Moved from @bitgo/utxo-core. + */ +import { Descriptor } from "../../index.js"; + +/** PSBT input (minimal type inlined from bip174) */ +export type PsbtInput = { + witnessUtxo?: { script: Uint8Array; value: bigint } | null; + bip32Derivation?: Array<{ path: string }>; + tapBip32Derivation?: Array<{ path: string }>; +}; + +/** PSBT output (minimal type inlined from bip174) */ +export type PsbtOutput = { + bip32Derivation?: Array<{ path: string }>; + tapBip32Derivation?: Array<{ path: string }>; +}; + +import { DescriptorMap } from "../DescriptorMap.js"; + +/** + * Compare two script byte arrays for equality. + */ +function scriptsEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +type DescriptorWithoutIndex = { descriptor: Descriptor; index: undefined }; + +/** + * Find a definite descriptor in the descriptor map that matches the given script. + * @param script + * @param descriptorMap + */ +function findDescriptorWithoutDerivation( + script: Uint8Array, + descriptorMap: DescriptorMap, +): DescriptorWithoutIndex | undefined { + for (const descriptor of descriptorMap.values()) { + if (!descriptor.hasWildcard()) { + if (scriptsEqual(descriptor.scriptPubkey(), script)) { + return { descriptor, index: undefined }; + } + } + } + + return undefined; +} + +type DescriptorWithIndex = { descriptor: Descriptor; index: number }; + +/** + * Find a descriptor in the descriptor map that matches the given script and derivation index. + * @param script + * @param index + * @param descriptorMap + * @returns DescriptorWithIndex if found, undefined otherwise + */ +function findDescriptorForDerivationIndex( + script: Uint8Array, + index: number, + descriptorMap: DescriptorMap, +): DescriptorWithIndex | undefined { + for (const descriptor of descriptorMap.values()) { + if ( + descriptor.hasWildcard() && + scriptsEqual(descriptor.atDerivationIndex(index).scriptPubkey(), script) + ) { + return { descriptor, index }; + } + } + + return undefined; +} + +function getDerivationIndexFromPath(path: string): number { + const indexStr = path.split("/").pop(); + if (!indexStr) { + throw new Error(`Invalid derivation path ${path}`); + } + const index = parseInt(indexStr, 10); + if (index.toString() !== indexStr) { + throw new Error(`Invalid derivation path ${path}`); + } + return index; +} + +/** + * Wrapper around findDescriptorForDerivationPath that tries multiple derivation paths. + * @param script + * @param derivationPaths + * @param descriptorMap + */ +function findDescriptorForAnyDerivationPath( + script: Uint8Array, + derivationPaths: string[], + descriptorMap: DescriptorMap, +): DescriptorWithIndex | undefined { + const derivationIndexSet = new Set(derivationPaths.map((p) => getDerivationIndexFromPath(p))); + for (const index of [...derivationIndexSet]) { + const desc = findDescriptorForDerivationIndex(script, index, descriptorMap); + if (desc) { + return desc; + } + } + + return undefined; +} + +type WithBip32Derivation = { bip32Derivation?: { path: string }[] }; +type WithTapBip32Derivation = { tapBip32Derivation?: { path: string }[] }; + +function getDerivationPaths(v: WithBip32Derivation | WithTapBip32Derivation): string[] | undefined { + if ("bip32Derivation" in v && v.bip32Derivation) { + return v.bip32Derivation.map((v) => v.path); + } + if ("tapBip32Derivation" in v && v.tapBip32Derivation) { + return v.tapBip32Derivation.map((v) => v.path).filter((v) => v !== "" && v !== "m"); + } + return undefined; +} + +/** + * @param input + * @param descriptorMap + * @returns DescriptorWithIndex for the input if found, undefined otherwise + */ +export function findDescriptorForInput( + input: PsbtInput, + descriptorMap: DescriptorMap, +): DescriptorWithIndex | DescriptorWithoutIndex | undefined { + const script = input.witnessUtxo?.script; + if (!script) { + throw new Error("Missing script"); + } + const derivationPaths = getDerivationPaths(input) ?? []; + return ( + findDescriptorWithoutDerivation(script, descriptorMap) ?? + findDescriptorForAnyDerivationPath(script, derivationPaths, descriptorMap) + ); +} + +/** + * @param script - the output script + * @param output - the PSBT output + * @param descriptorMap + * @returns DescriptorWithIndex for the output if found, undefined otherwise + */ +export function findDescriptorForOutput( + script: Uint8Array, + output: PsbtOutput, + descriptorMap: DescriptorMap, +): DescriptorWithIndex | DescriptorWithoutIndex | undefined { + const derivationPaths = getDerivationPaths(output); + return ( + findDescriptorWithoutDerivation(script, descriptorMap) ?? + (derivationPaths === undefined + ? undefined + : findDescriptorForAnyDerivationPath(script, derivationPaths, descriptorMap)) + ); +} diff --git a/packages/wasm-utxo/js/descriptorWallet/psbt/index.ts b/packages/wasm-utxo/js/descriptorWallet/psbt/index.ts new file mode 100644 index 0000000..d1186b2 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/psbt/index.ts @@ -0,0 +1,9 @@ +/** + * PSBT utilities for descriptor wallets. + * Moved from @bitgo/utxo-core. + */ +export * from "./assertSatisfiable.js"; +export * from "./createPsbt.js"; +export * from "./parse.js"; +export * from "./findDescriptors.js"; +export * from "./sign.js"; diff --git a/packages/wasm-utxo/js/descriptorWallet/psbt/parse.ts b/packages/wasm-utxo/js/descriptorWallet/psbt/parse.ts new file mode 100644 index 0000000..a5b1698 --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/psbt/parse.ts @@ -0,0 +1,175 @@ +/** + * PSBT parsing for descriptor wallets. + * Moved from @bitgo/utxo-core. + * + * This version uses pure TypeScript with PSBT introspection primitives. + */ +import { Descriptor, Psbt } from "../../index.js"; +import type { CoinName } from "../../coinName.js"; +import { fromOutputScriptWithCoin } from "../../address.js"; + +import { DescriptorMap } from "../DescriptorMap.js"; +import { + findDescriptorForInput, + findDescriptorForOutput, + PsbtInput, + PsbtOutput, +} from "./findDescriptors.js"; +import { getVirtualSize } from "../VirtualSize.js"; + +/** WASM PsbtOutputData has script and value in addition to PsbtOutput fields */ +type PsbtOutputData = PsbtOutput & { script: Uint8Array; value: bigint }; + +/** Identifier for a script derived from a descriptor */ +export type ScriptId = { + /** The descriptor that generated this script (reference-identical to the one in the map) */ + descriptor: Descriptor; + /** The derivation index, or undefined for definite descriptors */ + index: number | undefined; +}; + +export type ParsedInput = { + address: string; + value: bigint; + scriptId: ScriptId; +}; + +export type ParsedOutput = { + /** Address string if available (null for non-standard outputs) */ + address: string | null; + script: Uint8Array; + value: bigint; + /** Script identifier if the output matches a descriptor (undefined if no match) */ + scriptId: ScriptId | undefined; +}; + +export type ParsedDescriptorTransaction = { + inputs: ParsedInput[]; + outputs: ParsedOutput[]; + spendAmount: bigint; + minerFee: bigint; + virtualSize: number; +}; + +/** + * Try to get an address from a script, returning null for non-standard outputs. + */ +function tryGetAddress(script: Uint8Array, coin: CoinName): string | null { + try { + return fromOutputScriptWithCoin(script, coin); + } catch { + return null; + } +} + +/** + * Parse a PSBT and extract descriptor information. + * + * This function uses PSBT introspection to match inputs/outputs against + * the provided descriptor map. + * + * The returned descriptors are reference-identical to those in the input + * descriptorMap, allowing for `===` comparison. + * + * @param psbt - The wasm-utxo Psbt to parse + * @param descriptorMap - Map of descriptor names to descriptors + * @param coin - The coin name for address conversion (e.g., "btc", "tbtc") + * @returns Parsed transaction information + */ +export function parse( + psbt: Psbt, + descriptorMap: DescriptorMap, + coin: CoinName, +): ParsedDescriptorTransaction { + const rawInputs = psbt.getInputs() as PsbtInput[]; + const rawOutputs = psbt.getOutputs() as PsbtOutputData[]; + + let totalInputValue = 0n; + let totalOutputValue = 0n; + let spendAmount = 0n; + + const inputs: ParsedInput[] = rawInputs.map((inputData, i) => { + const witnessUtxo = inputData.witnessUtxo; + if (!witnessUtxo) { + throw new Error(`Missing witnessUtxo for input ${i}`); + } + + const scriptIdResult = findDescriptorForInput(inputData, descriptorMap); + if (!scriptIdResult) { + throw new Error(`No descriptor found for input ${i}`); + } + + const scriptId: ScriptId = { + descriptor: scriptIdResult.descriptor, + index: scriptIdResult.index, + }; + + totalInputValue += witnessUtxo.value; + + return { + address: fromOutputScriptWithCoin(witnessUtxo.script, coin), + value: witnessUtxo.value, + scriptId, + }; + }); + + const outputs: ParsedOutput[] = rawOutputs.map((outputData) => { + const scriptIdResult = findDescriptorForOutput(outputData.script, outputData, descriptorMap); + + const scriptId: ScriptId | undefined = scriptIdResult + ? { + descriptor: scriptIdResult.descriptor, + index: scriptIdResult.index, + } + : undefined; + + totalOutputValue += outputData.value; + + // Outputs without a matching descriptor are external (spend) outputs + if (!scriptId) { + spendAmount += outputData.value; + } + + return { + address: tryGetAddress(outputData.script, coin), + script: outputData.script, + value: outputData.value, + scriptId, + }; + }); + + const minerFee = totalInputValue - totalOutputValue; + + // Calculate virtual size using the descriptors from parsed inputs + const virtualSize = getVirtualSize({ + inputs: inputs.map((input) => input.scriptId.descriptor), + outputs: outputs.map((output) => ({ script: output.script })), + }); + + return { + inputs, + outputs, + spendAmount, + minerFee, + virtualSize, + }; +} + +/** + * Parse a serialized PSBT buffer with descriptor information. + * + * This is a convenience function that creates a Psbt from bytes before parsing. + * + * @param psbtBytes - The serialized PSBT bytes + * @param descriptorMap - Map of descriptor names to descriptors + * @param coin - The coin name for address conversion + * @returns Parsed transaction information + */ +export function parseFromBytes( + psbtBytes: Uint8Array, + descriptorMap: DescriptorMap, + coin: CoinName, +): ParsedDescriptorTransaction { + const psbt = Psbt.deserialize(psbtBytes); + return parse(psbt, descriptorMap, coin); +} diff --git a/packages/wasm-utxo/js/descriptorWallet/psbt/sign.ts b/packages/wasm-utxo/js/descriptorWallet/psbt/sign.ts new file mode 100644 index 0000000..c7a91ca --- /dev/null +++ b/packages/wasm-utxo/js/descriptorWallet/psbt/sign.ts @@ -0,0 +1,75 @@ +/** + * PSBT signing utilities for descriptor wallets. + * Moved from @bitgo/utxo-core. + */ +import { Psbt, BIP32 } from "../../index.js"; +import type { BIP32Interface } from "../../bip32.js"; +import { ECPair } from "../../ecpair.js"; + +/** These can be replaced when @bitgo/wasm-utxo is updated */ +export type SignPsbtInputResult = { Schnorr: string[] } | { Ecdsa: string[] }; +export type SignPsbtResult = { + [inputIndex: number]: SignPsbtInputResult; +}; + +/** + * @param signResult + * @return the number of new signatures created by the signResult for a single input + */ +export function getNewSignatureCountForInput(signResult: SignPsbtInputResult): number { + if ("Schnorr" in signResult) { + return signResult.Schnorr.length; + } + if ("Ecdsa" in signResult) { + return signResult.Ecdsa.length; + } + throw new Error(`Unknown signature type ${Object.keys(signResult).join(", ")}`); +} + +/** + * @param signResult + * @return the number of new signatures created by the signResult + */ +export function getNewSignatureCount(signResult: SignPsbtResult): number { + return Object.values(signResult).reduce( + (sum, signatures) => sum + getNewSignatureCountForInput(signatures), + 0, + ); +} + +type Key = + | Uint8Array + | BIP32Interface + | BIP32 + | ECPair + | { privateKey?: Uint8Array; toBase58?(): string }; + +/** Convenience function to sign a PSBT with a key */ +export function signWithKey(psbt: Psbt, key: Key): SignPsbtResult { + // Handle Uint8Array (raw private key) + if (key instanceof Uint8Array) { + return psbt.signWithPrv(key) as unknown as SignPsbtResult; + } + + // Handle BIP32 wrapper class + if (key instanceof BIP32) { + return psbt.signAll(key.wasm) as unknown as SignPsbtResult; + } + + // Handle ECPair wrapper class + if (key instanceof ECPair) { + return psbt.signAllWithEcpair(key.wasm) as unknown as SignPsbtResult; + } + + // Handle objects with toBase58 (BIP32Interface from utxolib) + if ("toBase58" in key && typeof key.toBase58 === "function") { + return psbt.signWithXprv(key.toBase58()) as unknown as SignPsbtResult; + } + + // Handle objects with privateKey (ECPairInterface) + if ("privateKey" in key && key.privateKey) { + return psbt.signWithPrv(key.privateKey) as unknown as SignPsbtResult; + } + + throw new Error("Invalid key type for signing"); +} diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 1ab1d2e..4a6dc12 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -12,6 +12,7 @@ export * as bip322 from "./bip322/index.js"; export * as inscriptions from "./inscriptions.js"; export * as utxolibCompat from "./utxolibCompat.js"; export * as fixedScriptWallet from "./fixedScriptWallet/index.js"; +export * as descriptorWallet from "./descriptorWallet/index.js"; export * as bip32 from "./bip32.js"; export * as ecpair from "./ecpair.js"; export * as testutils from "./testutils/index.js"; @@ -59,6 +60,33 @@ declare module "./wasm/wasm_utxo.js" { function fromBitcoinScript(script: Uint8Array, ctx: ScriptContext): WrapMiniscript; } + /** BIP32 derivation data from a PSBT */ + interface PsbtBip32Derivation { + pubkey: Uint8Array; + path: string; + } + + /** Witness UTXO data from a PSBT input */ + interface PsbtWitnessUtxo { + script: Uint8Array; + value: bigint; + } + + /** Raw PSBT input data returned by getInputs() */ + interface PsbtInputData { + witnessUtxo: PsbtWitnessUtxo | null; + bip32Derivation: PsbtBip32Derivation[]; + tapBip32Derivation: PsbtBip32Derivation[]; + } + + /** Raw PSBT output data returned by getOutputs() */ + interface PsbtOutputData { + script: Uint8Array; + value: bigint; + bip32Derivation: PsbtBip32Derivation[]; + tapBip32Derivation: PsbtBip32Derivation[]; + } + interface WrapPsbt { // Signing methods (legacy - kept for backwards compatibility) signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult; @@ -71,6 +99,8 @@ declare module "./wasm/wasm_utxo.js" { // Introspection methods inputCount(): number; outputCount(): number; + getInputs(): PsbtInputData[]; + getOutputs(): PsbtOutputData[]; getPartialSignatures(inputIndex: number): Array<{ pubkey: Uint8Array; signature: Uint8Array; diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index 97bfb45..9903f0e 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -79,6 +79,103 @@ impl psbt::GetKey for SingleKeySigner { } } +// ============================================================================ +// PSBT Introspection Types +// ============================================================================ + +/// BIP32 derivation information +#[derive(Debug, Clone)] +pub struct Bip32Derivation { + pub pubkey: Vec, + pub path: String, +} + +/// Witness UTXO information +#[derive(Debug, Clone)] +pub struct WitnessUtxo { + pub script: Vec, + pub value: u64, +} + +/// Raw PSBT input data for introspection +#[derive(Debug, Clone)] +pub struct PsbtInputData { + pub witness_utxo: Option, + pub bip32_derivation: Vec, + pub tap_bip32_derivation: Vec, +} + +impl From<&psbt::Input> for PsbtInputData { + fn from(input: &psbt::Input) -> Self { + let witness_utxo = input.witness_utxo.as_ref().map(|utxo| WitnessUtxo { + script: utxo.script_pubkey.to_bytes(), + value: utxo.value.to_sat(), + }); + + let bip32_derivation: Vec = input + .bip32_derivation + .iter() + .map(|(pubkey, (_, path))| Bip32Derivation { + pubkey: pubkey.serialize().to_vec(), + path: path.to_string(), + }) + .collect(); + + let tap_bip32_derivation: Vec = input + .tap_key_origins + .iter() + .map(|(xonly_pubkey, (_, (_, path)))| Bip32Derivation { + pubkey: xonly_pubkey.serialize().to_vec(), + path: path.to_string(), + }) + .collect(); + + PsbtInputData { + witness_utxo, + bip32_derivation, + tap_bip32_derivation, + } + } +} + +/// Raw PSBT output data for introspection +#[derive(Debug, Clone)] +pub struct PsbtOutputData { + pub script: Vec, + pub value: u64, + pub bip32_derivation: Vec, + pub tap_bip32_derivation: Vec, +} + +impl PsbtOutputData { + pub fn from(tx_out: &TxOut, psbt_out: &psbt::Output) -> Self { + let bip32_derivation: Vec = psbt_out + .bip32_derivation + .iter() + .map(|(pubkey, (_, path))| Bip32Derivation { + pubkey: pubkey.serialize().to_vec(), + path: path.to_string(), + }) + .collect(); + + let tap_bip32_derivation: Vec = psbt_out + .tap_key_origins + .iter() + .map(|(xonly_pubkey, (_, (_, path)))| Bip32Derivation { + pubkey: xonly_pubkey.serialize().to_vec(), + path: path.to_string(), + }) + .collect(); + + PsbtOutputData { + script: tx_out.script_pubkey.to_bytes(), + value: tx_out.value.to_sat(), + bip32_derivation, + tap_bip32_derivation, + } + } +} + #[wasm_bindgen] pub struct WrapPsbt(Psbt); @@ -436,6 +533,33 @@ impl WrapPsbt { self.0.outputs.len() } + /// Get all PSBT inputs as an array of PsbtInputData + /// + /// Returns an array with witness_utxo, bip32_derivation, and tap_bip32_derivation + /// for each input. This is useful for introspecting the PSBT structure. + #[wasm_bindgen(js_name = getInputs)] + pub fn get_inputs(&self) -> Result { + let inputs: Vec = self.0.inputs.iter().map(PsbtInputData::from).collect(); + inputs.try_to_js_value() + } + + /// Get all PSBT outputs as an array of PsbtOutputData + /// + /// Returns an array with script, value, bip32_derivation, and tap_bip32_derivation + /// for each output. This is useful for introspecting the PSBT structure. + #[wasm_bindgen(js_name = getOutputs)] + pub fn get_outputs(&self) -> Result { + let outputs: Vec = self + .0 + .unsigned_tx + .output + .iter() + .zip(self.0.outputs.iter()) + .map(|(tx_out, psbt_out)| PsbtOutputData::from(tx_out, psbt_out)) + .collect(); + outputs.try_to_js_value() + } + /// Get partial signatures for an input /// Returns array of { pubkey: Uint8Array, signature: Uint8Array } #[wasm_bindgen(js_name = getPartialSignatures)] diff --git a/packages/wasm-utxo/src/wasm/try_into_js_value.rs b/packages/wasm-utxo/src/wasm/try_into_js_value.rs index fd18fd3..8858cef 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -404,6 +404,49 @@ impl TryIntoJsValue for crate::inscriptions::InscriptionRevealData { } } +// ============================================================================ +// PSBT Introspection Types TryIntoJsValue implementations +// ============================================================================ + +impl TryIntoJsValue for crate::wasm::psbt::Bip32Derivation { + fn try_to_js_value(&self) -> Result { + js_obj!( + "pubkey" => self.pubkey.clone(), + "path" => self.path.clone() + ) + } +} + +impl TryIntoJsValue for crate::wasm::psbt::WitnessUtxo { + fn try_to_js_value(&self) -> Result { + js_obj!( + "script" => self.script.clone(), + "value" => self.value + ) + } +} + +impl TryIntoJsValue for crate::wasm::psbt::PsbtInputData { + fn try_to_js_value(&self) -> Result { + js_obj!( + "witnessUtxo" => self.witness_utxo.clone(), + "bip32Derivation" => self.bip32_derivation.clone(), + "tapBip32Derivation" => self.tap_bip32_derivation.clone() + ) + } +} + +impl TryIntoJsValue for crate::wasm::psbt::PsbtOutputData { + fn try_to_js_value(&self) -> Result { + js_obj!( + "script" => self.script.clone(), + "value" => self.value, + "bip32Derivation" => self.bip32_derivation.clone(), + "tapBip32Derivation" => self.tap_bip32_derivation.clone() + ) + } +} + /// A partial signature with its associated public key #[derive(Clone)] pub struct PartialSignature { diff --git a/packages/wasm-utxo/test/descriptorWallet/DescriptorMap.ts b/packages/wasm-utxo/test/descriptorWallet/DescriptorMap.ts new file mode 100644 index 0000000..063f582 --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/DescriptorMap.ts @@ -0,0 +1,42 @@ +import * as assert from "assert"; +import { Descriptor } from "../../js/index.js"; +import { toDescriptorMap } from "../../js/descriptorWallet/DescriptorMap.js"; + +describe("descriptorWallet/DescriptorMap", () => { + const testDescriptor = "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)"; + + describe("toDescriptorMap", () => { + it("should create map from descriptor strings", () => { + const map = toDescriptorMap([{ name: "external", value: testDescriptor }]); + + assert.strictEqual(map.size, 1); + assert.ok(map.has("external")); + const descriptor = map.get("external"); + assert.ok(descriptor instanceof Descriptor); + }); + + it("should create map from Descriptor instances", () => { + const descriptor = Descriptor.fromStringDetectType(testDescriptor); + const map = toDescriptorMap([{ name: "external", value: descriptor }]); + + assert.strictEqual(map.size, 1); + assert.strictEqual(map.get("external"), descriptor); + }); + + it("should handle multiple descriptors", () => { + const map = toDescriptorMap([ + { name: "external", value: testDescriptor }, + { name: "internal", value: testDescriptor }, + ]); + + assert.strictEqual(map.size, 2); + assert.ok(map.has("external")); + assert.ok(map.has("internal")); + }); + + it("should handle empty array", () => { + const map = toDescriptorMap([]); + assert.strictEqual(map.size, 0); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/Output.ts b/packages/wasm-utxo/test/descriptorWallet/Output.ts new file mode 100644 index 0000000..8000eda --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/Output.ts @@ -0,0 +1,103 @@ +import * as assert from "assert"; +import { + Output, + MaxOutput, + isMaxOutput, + getMaxOutput, + getOutputSum, + getFixedOutputSum, + toFixedOutputs, +} from "../../js/descriptorWallet/Output.js"; + +describe("descriptorWallet/Output", () => { + describe("isMaxOutput", () => { + it("should return true for max output", () => { + const output: MaxOutput = { script: Buffer.from("test"), value: "max" }; + assert.strictEqual(isMaxOutput(output), true); + }); + + it("should return false for fixed output", () => { + const output: Output = { script: Buffer.from("test"), value: 1000n }; + assert.strictEqual(isMaxOutput(output), false); + }); + }); + + describe("getMaxOutput", () => { + it("should return undefined when no max output", () => { + const outputs: Output[] = [ + { script: Buffer.from("a"), value: 100n }, + { script: Buffer.from("b"), value: 200n }, + ]; + assert.strictEqual(getMaxOutput(outputs), undefined); + }); + + it("should return the max output when present", () => { + const maxOutput: MaxOutput = { script: Buffer.from("max"), value: "max" }; + const outputs: (Output | MaxOutput)[] = [ + { script: Buffer.from("a"), value: 100n }, + maxOutput, + ]; + assert.strictEqual(getMaxOutput(outputs), maxOutput); + }); + + it("should throw when multiple max outputs", () => { + const outputs: MaxOutput[] = [ + { script: Buffer.from("a"), value: "max" }, + { script: Buffer.from("b"), value: "max" }, + ]; + assert.throws(() => getMaxOutput(outputs), /Multiple max outputs/); + }); + }); + + describe("getOutputSum", () => { + it("should sum output values", () => { + const outputs: Output[] = [ + { script: Buffer.from("a"), value: 100n }, + { script: Buffer.from("b"), value: 200n }, + { script: Buffer.from("c"), value: 300n }, + ]; + assert.strictEqual(getOutputSum(outputs), 600n); + }); + + it("should return 0 for empty array", () => { + assert.strictEqual(getOutputSum([]), 0n); + }); + }); + + describe("getFixedOutputSum", () => { + it("should sum only fixed outputs, ignoring max", () => { + const outputs: (Output | MaxOutput)[] = [ + { script: Buffer.from("a"), value: 100n }, + { script: Buffer.from("max"), value: "max" }, + { script: Buffer.from("b"), value: 200n }, + ]; + assert.strictEqual(getFixedOutputSum(outputs), 300n); + }); + }); + + describe("toFixedOutputs", () => { + it("should replace max output with maxAmount", () => { + const outputs: (Output | MaxOutput)[] = [ + { script: Buffer.from("a"), value: 100n }, + { script: Buffer.from("max"), value: "max" }, + ]; + const fixed = toFixedOutputs(outputs, { maxAmount: 500n }); + + assert.strictEqual(fixed.length, 2); + assert.strictEqual(fixed[0].value, 100n); + assert.strictEqual(fixed[1].value, 500n); + }); + + it("should return same outputs when no max output", () => { + const outputs: Output[] = [ + { script: Buffer.from("a"), value: 100n }, + { script: Buffer.from("b"), value: 200n }, + ]; + const fixed = toFixedOutputs(outputs, { maxAmount: 500n }); + + assert.strictEqual(fixed.length, 2); + assert.strictEqual(fixed[0].value, 100n); + assert.strictEqual(fixed[1].value, 200n); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/PatternMatcher.ts b/packages/wasm-utxo/test/descriptorWallet/PatternMatcher.ts new file mode 100644 index 0000000..2faa314 --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/PatternMatcher.ts @@ -0,0 +1,134 @@ +import * as assert from "assert"; +import { PatternMatcher, Pattern } from "../../js/descriptorWallet/parse/PatternMatcher.js"; + +describe("descriptorWallet/PatternMatcher", () => { + const matcher = new PatternMatcher(); + + describe("match", () => { + it("should match exact strings", () => { + const result = matcher.match("hello", "hello"); + assert.deepStrictEqual(result, {}); + }); + + it("should not match different strings", () => { + const result = matcher.match("hello", "world"); + assert.strictEqual(result, null); + }); + + it("should match exact numbers", () => { + const result = matcher.match(42, 42); + assert.deepStrictEqual(result, {}); + }); + + it("should not match different numbers", () => { + const result = matcher.match(42, 43); + assert.strictEqual(result, null); + }); + + it("should capture variables", () => { + const pattern: Pattern = { $var: "x" }; + const result = matcher.match("hello", pattern); + assert.deepStrictEqual(result, { x: "hello" }); + }); + + it("should capture complex values in variables", () => { + const pattern: Pattern = { $var: "x" }; + const node = { foo: "bar", num: 42 }; + const result = matcher.match(node, pattern); + assert.deepStrictEqual(result, { x: node }); + }); + + it("should match arrays", () => { + const node = [1, 2, 3]; + const pattern: Pattern = [1, 2, 3]; + const result = matcher.match(node, pattern); + assert.deepStrictEqual(result, {}); + }); + + it("should not match arrays of different lengths", () => { + const node = [1, 2, 3]; + const pattern: Pattern = [1, 2]; + const result = matcher.match(node, pattern); + assert.strictEqual(result, null); + }); + + it("should match arrays with variables", () => { + const node = [1, 2, 3]; + const pattern: Pattern = [{ $var: "first" }, 2, { $var: "last" }]; + const result = matcher.match(node, pattern); + assert.deepStrictEqual(result, { first: 1, last: 3 }); + }); + + it("should match objects", () => { + const node = { a: 1, b: 2 }; + const pattern: Pattern = { a: 1, b: 2 }; + const result = matcher.match(node, pattern); + assert.deepStrictEqual(result, {}); + }); + + it("should not match objects with different keys", () => { + const node = { a: 1, b: 2 }; + const pattern: Pattern = { a: 1, c: 2 }; + const result = matcher.match(node, pattern); + assert.strictEqual(result, null); + }); + + it("should match objects with variables", () => { + const node = { a: 1, b: "hello" }; + const pattern: Pattern = { a: { $var: "num" }, b: { $var: "str" } }; + const result = matcher.match(node, pattern); + assert.deepStrictEqual(result, { num: 1, str: "hello" }); + }); + + it("should match nested structures", () => { + const node = { + outer: { + inner: [1, 2, 3], + value: "test", + }, + }; + const pattern: Pattern = { + outer: { + inner: [{ $var: "first" }, 2, { $var: "last" }], + value: { $var: "val" }, + }, + }; + const result = matcher.match(node, pattern); + assert.deepStrictEqual(result, { first: 1, last: 3, val: "test" }); + }); + + it("should require consistent variable values", () => { + const node = { a: 1, b: 1 }; + const pattern: Pattern = { a: { $var: "x" }, b: { $var: "x" } }; + const result = matcher.match(node, pattern); + assert.deepStrictEqual(result, { x: 1 }); + }); + + it("should fail when variable values are inconsistent", () => { + const node = { a: 1, b: 2 }; + const pattern: Pattern = { a: { $var: "x" }, b: { $var: "x" } }; + const result = matcher.match(node, pattern); + assert.strictEqual(result, null); + }); + + it("should match descriptor-like structures", () => { + // Example: matching a wsh(multi(...)) descriptor node + const node = { + Wsh: { + Ms: { + multi: [2, "key1", "key2", "key3"], + }, + }, + }; + const pattern: Pattern = { + Wsh: { + Ms: { + multi: { $var: "multiArgs" }, + }, + }, + }; + const result = matcher.match(node, pattern); + assert.deepStrictEqual(result, { multiArgs: [2, "key1", "key2", "key3"] }); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/VirtualSize.ts b/packages/wasm-utxo/test/descriptorWallet/VirtualSize.ts new file mode 100644 index 0000000..1cbe4ed --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/VirtualSize.ts @@ -0,0 +1,147 @@ +import * as assert from "assert"; +import { Descriptor, Psbt } from "../../js/index.js"; +import { + getInputVSizesForDescriptors, + getChangeOutputVSizesForDescriptor, + getVirtualSize, + getVirtualSizeEstimateForPsbt, +} from "../../js/descriptorWallet/VirtualSize.js"; +import { toDescriptorMap } from "../../js/descriptorWallet/DescriptorMap.js"; + +describe("descriptorWallet/VirtualSize", () => { + // P2WPKH descriptor + const wpkhDescriptor = "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)"; + + // P2WSH 2-of-2 multisig descriptor + const wshDescriptor = + "wsh(multi(2,02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5,03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556))"; + + describe("getInputVSizesForDescriptors", () => { + it("should calculate input vsize for P2WPKH", () => { + const descriptors = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const vsizes = getInputVSizesForDescriptors(descriptors); + + assert.ok("wpkh" in vsizes); + // P2WPKH input is approximately 68 vbytes + assert.ok( + vsizes["wpkh"] > 60 && vsizes["wpkh"] < 80, + `Expected P2WPKH vsize around 68, got ${vsizes["wpkh"]}`, + ); + }); + + it("should calculate input vsize for P2WSH", () => { + const descriptors = toDescriptorMap([{ name: "wsh", value: wshDescriptor }]); + + const vsizes = getInputVSizesForDescriptors(descriptors); + + assert.ok("wsh" in vsizes); + // P2WSH 2-of-2 multisig is larger than P2WPKH + assert.ok(vsizes["wsh"] > 80, `Expected P2WSH vsize > 80, got ${vsizes["wsh"]}`); + }); + + it("should handle multiple descriptors", () => { + const descriptors = toDescriptorMap([ + { name: "wpkh", value: wpkhDescriptor }, + { name: "wsh", value: wshDescriptor }, + ]); + + const vsizes = getInputVSizesForDescriptors(descriptors); + + assert.ok("wpkh" in vsizes); + assert.ok("wsh" in vsizes); + }); + }); + + describe("getChangeOutputVSizesForDescriptor", () => { + it("should return input and output vsize for P2WPKH", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const sizes = getChangeOutputVSizesForDescriptor(descriptor); + + assert.ok(typeof sizes.inputVSize === "number"); + assert.ok(typeof sizes.outputVSize === "number"); + // P2WPKH output is 31 bytes (8 value + 1 + 22 scriptPubKey) + assert.strictEqual(sizes.outputVSize, 22); + }); + + it("should return input and output vsize for P2WSH", () => { + const descriptor = Descriptor.fromStringDetectType(wshDescriptor); + const sizes = getChangeOutputVSizesForDescriptor(descriptor); + + // P2WSH output is 43 bytes (8 value + 1 + 34 scriptPubKey) + assert.strictEqual(sizes.outputVSize, 34); + }); + }); + + describe("getVirtualSize", () => { + it("should calculate vsize with Descriptor inputs", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + const tx = { + inputs: [descriptor], + outputs: [{ script }], + }; + + const vsize = getVirtualSize(tx); + + // Should be > 0 and reasonable + assert.ok(vsize > 50 && vsize < 200, `Unexpected vsize: ${vsize}`); + }); + + it("should calculate vsize with descriptorName inputs", () => { + const descriptors = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + const tx = { + inputs: [{ descriptorName: "wpkh" }], + outputs: [{ script }], + }; + + const vsize = getVirtualSize(tx, descriptors); + + assert.ok(vsize > 50 && vsize < 200, `Unexpected vsize: ${vsize}`); + }); + + it("should throw for descriptorName without descriptorMap", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + const tx = { + inputs: [{ descriptorName: "wpkh" }], + outputs: [{ script }], + }; + + // @ts-expect-error - testing error case with missing descriptorMap + assert.throws(() => getVirtualSize(tx), /missing descriptorMap/); + }); + }); + + describe("getVirtualSizeEstimateForPsbt", () => { + it("should estimate vsize for PSBT", () => { + const descriptors = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + // Create a minimal PSBT + const psbt = new Psbt(2, 0); + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + // Add a dummy input + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000000", + 0, + 100000n, + script, + ); + + // Add a dummy output + psbt.addOutput(script, 50000n); + + const vsize = getVirtualSizeEstimateForPsbt(psbt, descriptors); + + assert.ok(vsize > 0, "vsize should be positive"); + assert.ok(vsize < 500, `vsize seems too large: ${vsize}`); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/address.ts b/packages/wasm-utxo/test/descriptorWallet/address.ts new file mode 100644 index 0000000..72543ca --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/address.ts @@ -0,0 +1,75 @@ +import * as assert from "assert"; +import { Descriptor } from "../../js/index.js"; +import { + createScriptPubKeyFromDescriptor, + createAddressFromDescriptor, +} from "../../js/descriptorWallet/address.js"; + +describe("descriptorWallet/address", () => { + // A definite P2WPKH descriptor + const definiteDescriptor = + "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)"; + + // A derivable descriptor with wildcard + const derivableDescriptor = + "wpkh(xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5/0/*)"; + + describe("createScriptPubKeyFromDescriptor", () => { + it("should create scriptPubKey for definite descriptor", () => { + const descriptor = Descriptor.fromStringDetectType(definiteDescriptor); + const script = createScriptPubKeyFromDescriptor(descriptor, undefined); + + assert.ok(script instanceof Uint8Array); + // P2WPKH scripts are 22 bytes (OP_0 + 20 byte hash) + assert.strictEqual(script.length, 22); + assert.strictEqual(script[0], 0x00); // OP_0 + assert.strictEqual(script[1], 0x14); // 20 bytes + }); + + it("should create scriptPubKey for derived descriptor", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const script = createScriptPubKeyFromDescriptor(descriptor, 0); + + assert.ok(script instanceof Uint8Array); + assert.strictEqual(script.length, 22); + }); + + it("should create different scripts for different indices", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const script0 = createScriptPubKeyFromDescriptor(descriptor, 0); + const script1 = createScriptPubKeyFromDescriptor(descriptor, 1); + + assert.notDeepStrictEqual(script0, script1); + }); + }); + + describe("createAddressFromDescriptor", () => { + it("should create Bitcoin mainnet address", () => { + const descriptor = Descriptor.fromStringDetectType(definiteDescriptor); + const address = createAddressFromDescriptor(descriptor, undefined, "btc"); + + assert.ok(typeof address === "string"); + // P2WPKH mainnet addresses start with bc1q + assert.ok(address.startsWith("bc1q"), `Expected bc1q prefix, got: ${address}`); + }); + + it("should create Bitcoin testnet address", () => { + const descriptor = Descriptor.fromStringDetectType(definiteDescriptor); + const address = createAddressFromDescriptor(descriptor, undefined, "tbtc"); + + assert.ok(typeof address === "string"); + // P2WPKH testnet addresses start with tb1q + assert.ok(address.startsWith("tb1q"), `Expected tb1q prefix, got: ${address}`); + }); + + it("should create addresses for derived descriptors", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const address0 = createAddressFromDescriptor(descriptor, 0, "btc"); + const address1 = createAddressFromDescriptor(descriptor, 1, "btc"); + + assert.notStrictEqual(address0, address1); + assert.ok(address0.startsWith("bc1q")); + assert.ok(address1.startsWith("bc1q")); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/derive.ts b/packages/wasm-utxo/test/descriptorWallet/derive.ts new file mode 100644 index 0000000..770caa1 --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/derive.ts @@ -0,0 +1,97 @@ +import * as assert from "assert"; +import { Descriptor } from "../../js/index.js"; +import { + getDescriptorAtIndex, + getDescriptorAtIndexCheckScript, +} from "../../js/descriptorWallet/derive.js"; + +describe("descriptorWallet/derive", () => { + // A definite descriptor (no wildcard) + const definiteDescriptor = + "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)"; + + // A derivable descriptor (with wildcard) - using a test xpub + const derivableDescriptor = + "wpkh(xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5/0/*)"; + + describe("getDescriptorAtIndex", () => { + it("should return definite descriptor without index", () => { + const descriptor = Descriptor.fromStringDetectType(definiteDescriptor); + const result = getDescriptorAtIndex(descriptor, undefined); + + assert.ok(result instanceof Descriptor); + assert.strictEqual(result.hasWildcard(), false); + }); + + it("should throw for definite descriptor with index", () => { + const descriptor = Descriptor.fromStringDetectType(definiteDescriptor); + + assert.throws( + () => getDescriptorAtIndex(descriptor, 0), + /Definite descriptor cannot be derived with index/, + ); + }); + + it("should derive derivable descriptor at index", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const result = getDescriptorAtIndex(descriptor, 0); + + assert.ok(result instanceof Descriptor); + assert.strictEqual(result.hasWildcard(), false); + }); + + it("should throw for derivable descriptor without index", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + + assert.throws( + () => getDescriptorAtIndex(descriptor, undefined), + /Derivable descriptor requires an index/, + ); + }); + + it("should derive different addresses for different indices", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const result0 = getDescriptorAtIndex(descriptor, 0); + const result1 = getDescriptorAtIndex(descriptor, 1); + + // Scripts should be different + assert.notDeepStrictEqual( + Buffer.from(result0.scriptPubkey()), + Buffer.from(result1.scriptPubkey()), + ); + }); + }); + + describe("getDescriptorAtIndexCheckScript", () => { + it("should return descriptor when script matches", () => { + const descriptor = Descriptor.fromStringDetectType(definiteDescriptor); + const script = Buffer.from(descriptor.scriptPubkey()); + + const result = getDescriptorAtIndexCheckScript(descriptor, undefined, script); + assert.ok(result instanceof Descriptor); + }); + + it("should throw when script does not match", () => { + const descriptor = Descriptor.fromStringDetectType(definiteDescriptor); + const wrongScript = Buffer.from([ + 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + + assert.throws( + () => getDescriptorAtIndexCheckScript(descriptor, undefined, wrongScript), + /Script mismatch/, + ); + }); + + it("should work with derivable descriptor at index", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const derived = descriptor.atDerivationIndex(5); + const script = Buffer.from(derived.scriptPubkey()); + + const result = getDescriptorAtIndexCheckScript(descriptor, 5, script); + assert.ok(result instanceof Descriptor); + assert.strictEqual(result.hasWildcard(), false); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/psbt/assertSatisfiable.ts b/packages/wasm-utxo/test/descriptorWallet/psbt/assertSatisfiable.ts new file mode 100644 index 0000000..d15bafd --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/psbt/assertSatisfiable.ts @@ -0,0 +1,106 @@ +import * as assert from "assert"; +import { Descriptor, Psbt } from "../../../js/index.js"; +import { + getRequiredLocktime, + assertSatisfiable, + FINAL_SEQUENCE, +} from "../../../js/descriptorWallet/psbt/assertSatisfiable.js"; + +describe("descriptorWallet/psbt/assertSatisfiable", () => { + describe("FINAL_SEQUENCE", () => { + it("should be 0xffffffff", () => { + assert.strictEqual(FINAL_SEQUENCE, 0xffffffff); + }); + }); + + describe("getRequiredLocktime", () => { + it("should return undefined for simple descriptor", () => { + const descriptor = Descriptor.fromStringDetectType( + "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)", + ); + const locktime = getRequiredLocktime(descriptor); + assert.strictEqual(locktime, undefined); + }); + + it("should return undefined for non-descriptor values", () => { + assert.strictEqual(getRequiredLocktime(null), undefined); + assert.strictEqual(getRequiredLocktime(undefined), undefined); + assert.strictEqual(getRequiredLocktime("string"), undefined); + assert.strictEqual(getRequiredLocktime(123), undefined); + }); + + it("should extract locktime from After node", () => { + const node = { + After: { absLockTime: 500000 }, + }; + const locktime = getRequiredLocktime(node); + assert.strictEqual(locktime, 500000); + }); + + it("should extract locktime from nested Wsh node", () => { + const node = { + Wsh: { + After: { absLockTime: 600000 }, + }, + }; + const locktime = getRequiredLocktime(node); + assert.strictEqual(locktime, 600000); + }); + + it("should extract locktime from AndV node", () => { + const node = { + AndV: [{ After: { absLockTime: 700000 } }, { pk: "somepubkey" }], + }; + const locktime = getRequiredLocktime(node); + assert.strictEqual(locktime, 700000); + }); + + it("should extract locktime from second AndV element", () => { + const node = { + AndV: [{ pk: "somepubkey" }, { After: { absLockTime: 800000 } }], + }; + const locktime = getRequiredLocktime(node); + assert.strictEqual(locktime, 800000); + }); + }); + + describe("assertSatisfiable", () => { + it("should pass for simple descriptor with any locktime", () => { + const descriptor = Descriptor.fromStringDetectType( + "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)", + ); + const script = Buffer.from(descriptor.scriptPubkey()); + + const psbt = new Psbt(2, 0); + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000000", + 0, + 100000n, + script, + ); + + // Should not throw + assertSatisfiable(psbt, 0, descriptor); + }); + + it("should pass when locktime matches required", () => { + // Create a descriptor with a locktime requirement + // For this test, we'll use a simple descriptor and mock the node + const descriptor = Descriptor.fromStringDetectType( + "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)", + ); + const script = Buffer.from(descriptor.scriptPubkey()); + + const psbt = new Psbt(2, 500000); + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000000", + 0, + 100000n, + script, + ); + + // Should not throw for descriptor without locktime requirement + assertSatisfiable(psbt, 0, descriptor); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/psbt/findDescriptors.ts b/packages/wasm-utxo/test/descriptorWallet/psbt/findDescriptors.ts new file mode 100644 index 0000000..41bd031 --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/psbt/findDescriptors.ts @@ -0,0 +1,161 @@ +import * as assert from "assert"; +import { Descriptor } from "../../../js/index.js"; +import { + findDescriptorForInput, + findDescriptorForOutput, + PsbtInput, + PsbtOutput, +} from "../../../js/descriptorWallet/psbt/findDescriptors.js"; +import { toDescriptorMap } from "../../../js/descriptorWallet/DescriptorMap.js"; + +describe("descriptorWallet/psbt/findDescriptors", () => { + // Definite descriptors + const wpkhDescriptor = "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)"; + const wshDescriptor = + "wsh(multi(2,02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5,03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556))"; + + // Derivable descriptor + const derivableDescriptor = + "wpkh(xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5/0/*)"; + + describe("findDescriptorForInput", () => { + it("should find definite descriptor matching script", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const input: PsbtInput = { + witnessUtxo: { script, value: 100000n }, + }; + + const result = findDescriptorForInput(input, descriptorMap); + + assert.ok(result); + assert.strictEqual(result.index, undefined); + assert.ok(result.descriptor instanceof Descriptor); + }); + + it("should find derivable descriptor using bip32Derivation", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const derivedScript = descriptor.atDerivationIndex(5).scriptPubkey(); + + const descriptorMap = toDescriptorMap([{ name: "derivable", value: derivableDescriptor }]); + + const input: PsbtInput = { + witnessUtxo: { script: derivedScript, value: 100000n }, + bip32Derivation: [{ path: "m/0/5" }], + }; + + const result = findDescriptorForInput(input, descriptorMap); + + assert.ok(result); + assert.strictEqual(result.index, 5); + }); + + it("should find derivable descriptor using tapBip32Derivation", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const derivedScript = descriptor.atDerivationIndex(10).scriptPubkey(); + + const descriptorMap = toDescriptorMap([{ name: "derivable", value: derivableDescriptor }]); + + const input: PsbtInput = { + witnessUtxo: { script: derivedScript, value: 100000n }, + tapBip32Derivation: [{ path: "m/0/10" }], + }; + + const result = findDescriptorForInput(input, descriptorMap); + + assert.ok(result); + assert.strictEqual(result.index, 10); + }); + + it("should return undefined when no matching descriptor", () => { + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + // Use a different script that doesn't match + const wshScript = Descriptor.fromStringDetectType(wshDescriptor).scriptPubkey(); + + const input: PsbtInput = { + witnessUtxo: { script: wshScript, value: 100000n }, + }; + + const result = findDescriptorForInput(input, descriptorMap); + + assert.strictEqual(result, undefined); + }); + + it("should throw when witnessUtxo is missing", () => { + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const input: PsbtInput = {}; + + assert.throws(() => findDescriptorForInput(input, descriptorMap), /Missing script/); + }); + + it("should prefer definite descriptor over derivation", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + const descriptorMap = toDescriptorMap([ + { name: "wpkh", value: wpkhDescriptor }, + { name: "derivable", value: derivableDescriptor }, + ]); + + const input: PsbtInput = { + witnessUtxo: { script, value: 100000n }, + bip32Derivation: [{ path: "m/0/0" }], + }; + + const result = findDescriptorForInput(input, descriptorMap); + + assert.ok(result); + // Should find the definite descriptor (index undefined) + assert.strictEqual(result.index, undefined); + }); + }); + + describe("findDescriptorForOutput", () => { + it("should find definite descriptor for output", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const output: PsbtOutput = {}; + + const result = findDescriptorForOutput(script, output, descriptorMap); + + assert.ok(result); + assert.strictEqual(result.index, undefined); + }); + + it("should find derivable descriptor using bip32Derivation", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const script = descriptor.atDerivationIndex(3).scriptPubkey(); + + const descriptorMap = toDescriptorMap([{ name: "derivable", value: derivableDescriptor }]); + + const output: PsbtOutput = { + bip32Derivation: [{ path: "m/0/3" }], + }; + + const result = findDescriptorForOutput(script, output, descriptorMap); + + assert.ok(result); + assert.strictEqual(result.index, 3); + }); + + it("should return undefined when no matching descriptor", () => { + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const wshScript = Descriptor.fromStringDetectType(wshDescriptor).scriptPubkey(); + + const output: PsbtOutput = {}; + + const result = findDescriptorForOutput(wshScript, output, descriptorMap); + + assert.strictEqual(result, undefined); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/psbt/parse.ts b/packages/wasm-utxo/test/descriptorWallet/psbt/parse.ts new file mode 100644 index 0000000..f44febe --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/psbt/parse.ts @@ -0,0 +1,219 @@ +import * as assert from "assert"; +import { Descriptor, Psbt } from "../../../js/index.js"; +import { toDescriptorMap } from "../../../js/descriptorWallet/DescriptorMap.js"; +import { parse, parseFromBytes } from "../../../js/descriptorWallet/psbt/parse.js"; + +describe("descriptorWallet/psbt/parse", () => { + // Test descriptors + const wpkhDescriptor = "wpkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)"; + const derivableDescriptor = + "wpkh(xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5/0/*)"; + + describe("parse", () => { + it("should parse a simple PSBT with one input and one output", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + // Create a simple PSBT + const psbt = new Psbt(2, 0); + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, + 100000n, + script, + ); + psbt.addOutput(script, 90000n); + + // Update with descriptor for proper bip32 derivation data + psbt.updateInputWithDescriptor(0, descriptor); + psbt.updateOutputWithDescriptor(0, descriptor); + + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const result = parse(psbt, descriptorMap, "btc"); + + // Check inputs + assert.strictEqual(result.inputs.length, 1); + assert.strictEqual(result.inputs[0].value, 100000n); + assert.ok(result.inputs[0].address.startsWith("bc1q")); + assert.ok(result.inputs[0].scriptId); + assert.strictEqual(result.inputs[0].scriptId.index, undefined); // definite descriptor + // Verify descriptor is returned as a Descriptor instance + assert.ok(result.inputs[0].scriptId.descriptor instanceof Descriptor); + + // Check outputs + assert.strictEqual(result.outputs.length, 1); + assert.strictEqual(result.outputs[0].value, 90000n); + assert.ok(result.outputs[0].address?.startsWith("bc1q")); + assert.ok(result.outputs[0].scriptId); + // Verify descriptor is returned as a Descriptor instance + assert.ok(result.outputs[0].scriptId.descriptor instanceof Descriptor); + + // Verify reference identity - descriptors should be the same object from the map + const mapDescriptor = descriptorMap.get("wpkh"); + assert.strictEqual( + result.inputs[0].scriptId.descriptor, + mapDescriptor, + "Input descriptor should be reference-identical to the one in the map", + ); + assert.strictEqual( + result.outputs[0].scriptId.descriptor, + mapDescriptor, + "Output descriptor should be reference-identical to the one in the map", + ); + + // Check calculated values + assert.strictEqual(result.minerFee, 10000n); + assert.strictEqual(result.spendAmount, 0n); // All outputs match descriptors + assert.ok(result.virtualSize > 0); + }); + + it("should parse PSBT with derivable descriptor", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const derivedDescriptor = descriptor.atDerivationIndex(5); + const script = derivedDescriptor.scriptPubkey(); + + const psbt = new Psbt(2, 0); + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000002", + 0, + 50000n, + script, + ); + psbt.addOutput(script, 40000n); + + // Update with descriptor + psbt.updateInputWithDescriptor(0, derivedDescriptor); + psbt.updateOutputWithDescriptor(0, derivedDescriptor); + + const descriptorMap = toDescriptorMap([{ name: "derivable", value: derivableDescriptor }]); + + const result = parse(psbt, descriptorMap, "btc"); + + assert.strictEqual(result.inputs.length, 1); + assert.strictEqual(result.inputs[0].value, 50000n); + assert.strictEqual(result.inputs[0].scriptId.index, 5); + + assert.strictEqual(result.outputs.length, 1); + assert.strictEqual(result.outputs[0].value, 40000n); + + // Verify reference identity for derivable descriptor + const mapDescriptor = descriptorMap.get("derivable"); + assert.strictEqual( + result.inputs[0].scriptId.descriptor, + mapDescriptor, + "Derivable descriptor should be reference-identical", + ); + + assert.strictEqual(result.minerFee, 10000n); + }); + + it("should calculate spendAmount for outputs without matching descriptors", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + // Create a different output script (external address) - P2WPKH with a different hash + const externalScript = new Uint8Array([ + 0x00, + 0x14, // OP_0 PUSH(20) + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, // random hash + ]); + + const psbt = new Psbt(2, 0); + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000003", + 0, + 100000n, + script, + ); + psbt.addOutput(externalScript, 60000n); // External output (spend) + psbt.addOutput(script, 30000n); // Change output (matches descriptor) + + psbt.updateInputWithDescriptor(0, descriptor); + psbt.updateOutputWithDescriptor(1, descriptor); + + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const result = parse(psbt, descriptorMap, "btc"); + + assert.strictEqual(result.outputs.length, 2); + assert.strictEqual(result.outputs[0].scriptId, undefined); // No matching descriptor + assert.ok(result.outputs[1].scriptId); // Matches descriptor + + assert.strictEqual(result.spendAmount, 60000n); + assert.strictEqual(result.minerFee, 10000n); + }); + + it("should work with testnet addresses", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + const psbt = new Psbt(2, 0); + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000004", + 0, + 100000n, + script, + ); + psbt.addOutput(script, 90000n); + + psbt.updateInputWithDescriptor(0, descriptor); + psbt.updateOutputWithDescriptor(0, descriptor); + + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const result = parse(psbt, descriptorMap, "tbtc"); + + assert.ok(result.inputs[0].address.startsWith("tb1q")); + assert.ok(result.outputs[0].address?.startsWith("tb1q")); + }); + }); + + describe("parseFromBytes", () => { + it("should parse PSBT from serialized bytes", () => { + const descriptor = Descriptor.fromStringDetectType(wpkhDescriptor); + const script = descriptor.scriptPubkey(); + + const psbt = new Psbt(2, 0); + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000005", + 0, + 100000n, + script, + ); + psbt.addOutput(script, 90000n); + + psbt.updateInputWithDescriptor(0, descriptor); + psbt.updateOutputWithDescriptor(0, descriptor); + + const psbtBytes = psbt.serialize(); + + const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); + + const result = parseFromBytes(psbtBytes, descriptorMap, "btc"); + + assert.strictEqual(result.inputs.length, 1); + assert.strictEqual(result.outputs.length, 1); + assert.strictEqual(result.minerFee, 10000n); + }); + }); +}); diff --git a/packages/wasm-utxo/test/descriptorWallet/psbt/sign.ts b/packages/wasm-utxo/test/descriptorWallet/psbt/sign.ts new file mode 100644 index 0000000..e0c5a30 --- /dev/null +++ b/packages/wasm-utxo/test/descriptorWallet/psbt/sign.ts @@ -0,0 +1,88 @@ +import * as assert from "assert"; +import { BIP32 } from "../../../js/bip32.js"; +import { ECPair } from "../../../js/ecpair.js"; +import { + getNewSignatureCountForInput, + getNewSignatureCount, + SignPsbtInputResult, + SignPsbtResult, +} from "../../../js/descriptorWallet/psbt/sign.js"; + +describe("descriptorWallet/psbt/sign", () => { + describe("getNewSignatureCountForInput", () => { + it("should count Ecdsa signatures", () => { + const result: SignPsbtInputResult = { + Ecdsa: ["pubkey1", "pubkey2"], + }; + assert.strictEqual(getNewSignatureCountForInput(result), 2); + }); + + it("should count Schnorr signatures", () => { + const result: SignPsbtInputResult = { + Schnorr: ["pubkey1"], + }; + assert.strictEqual(getNewSignatureCountForInput(result), 1); + }); + + it("should return 0 for empty signatures", () => { + const result: SignPsbtInputResult = { + Ecdsa: [], + }; + assert.strictEqual(getNewSignatureCountForInput(result), 0); + }); + }); + + describe("getNewSignatureCount", () => { + it("should sum signatures across all inputs", () => { + const result: SignPsbtResult = { + 0: { Ecdsa: ["pub1", "pub2"] }, + 1: { Ecdsa: ["pub3"] }, + 2: { Schnorr: ["pub4", "pub5", "pub6"] }, + }; + assert.strictEqual(getNewSignatureCount(result), 6); + }); + + it("should return 0 for empty result", () => { + const result: SignPsbtResult = {}; + assert.strictEqual(getNewSignatureCount(result), 0); + }); + }); + + describe("BIP32 key compatibility", () => { + it("should create BIP32 from seed string", () => { + const key = BIP32.fromSeedSha256("test-seed"); + assert.ok(key.privateKey); + assert.ok(key.publicKey); + }); + + it("should derive keys from BIP32", () => { + const master = BIP32.fromSeedSha256("test-seed"); + const derived = master.derivePath("m/44'/0'/0'/0/0"); + assert.ok(derived.privateKey); + assert.notDeepStrictEqual(derived.publicKey, master.publicKey); + }); + }); + + describe("ECPair key compatibility", () => { + it("should create ECPair from WIF", () => { + // This is a test WIF - DO NOT USE IN PRODUCTION + const wif = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"; + const pair = ECPair.fromWIF(wif); + assert.ok(pair.privateKey); + assert.ok(pair.publicKey); + }); + + it("should create ECPair from private key", () => { + // Create a deterministic private key for testing + const privateKey = Buffer.alloc(32); + for (let i = 0; i < 32; i++) { + privateKey[i] = i + 1; + } + const pair = ECPair.fromPrivateKey(privateKey); + + assert.ok(pair.privateKey); + assert.ok(pair.publicKey); + assert.strictEqual(pair.publicKey.length, 33); // Compressed public key + }); + }); +}); diff --git a/packages/wasm-utxo/tsconfig.cjs.json b/packages/wasm-utxo/tsconfig.cjs.json index f6f2384..7d47429 100644 --- a/packages/wasm-utxo/tsconfig.cjs.json +++ b/packages/wasm-utxo/tsconfig.cjs.json @@ -5,6 +5,5 @@ "moduleResolution": "node", "rootDir": ".", "outDir": "./dist/cjs" - }, - "exclude": ["test/**/*"] + } } diff --git a/packages/wasm-utxo/tsconfig.json b/packages/wasm-utxo/tsconfig.json index 97f8a7a..3c5fd12 100644 --- a/packages/wasm-utxo/tsconfig.json +++ b/packages/wasm-utxo/tsconfig.json @@ -11,8 +11,9 @@ "rootDir": ".", "outDir": "./dist/esm", "noUnusedLocals": true, - "noUnusedParameters": true + "noUnusedParameters": true, + "types": [] }, - "include": ["./js/**/*.ts", "test/**/*.ts"], + "include": ["./js/**/*.ts"], "exclude": ["node_modules", "./js/wasm/**/*"] } diff --git a/packages/wasm-utxo/tsconfig.test.json b/packages/wasm-utxo/tsconfig.test.json new file mode 100644 index 0000000..0dd2518 --- /dev/null +++ b/packages/wasm-utxo/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "mocha"], + "noEmit": true + }, + "include": ["./js/**/*.ts", "test/**/*.ts"] +}