diff --git a/packages/wasm-utxo/js/bip32.ts b/packages/wasm-utxo/js/bip32.ts index dd5329e..14471e0 100644 --- a/packages/wasm-utxo/js/bip32.ts +++ b/packages/wasm-utxo/js/bip32.ts @@ -228,6 +228,38 @@ export class BIP32 implements BIP32Interface { return new BIP32(wasm); } + /** + * Check equality with another BIP32 key. + * Two keys are equal if they have the same type (public/private) and identical + * BIP32 metadata (depth, parent fingerprint, child index, chain code, key data). + * This is a fast comparison that does not require serialization. + * + * @param other - The other key to compare with. Accepts BIP32, or any BIP32Interface. + * @returns True if the keys are equal + */ + equals(other: BIP32Interface): boolean { + const otherWasm = other instanceof BIP32 ? other._wasm : BIP32.from(other)._wasm; + return this._wasm.equals(otherWasm); + } + + /** + * Custom JSON representation for debugging. + * Always serializes the public key (xpub) to avoid leaking private keys. + * Includes a `hasPrivateKey` flag to indicate whether the key is neutered. + */ + toJSON(): { xpub: string; hasPrivateKey: boolean } { + return { xpub: this.neutered().toBase58(), hasPrivateKey: !this.isNeutered() }; + } + + /** + * Custom inspect representation for Node.js util.inspect and console.log. + * Always shows the public key (xpub) to avoid leaking private keys. + */ + [Symbol.for("nodejs.util.inspect.custom")](): string { + const flag = this.isNeutered() ? "" : ", hasPrivateKey"; + return `BIP32(${this.neutered().toBase58()}${flag})`; + } + /** * Get the underlying WASM instance (internal use only) * @internal diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 69d3512..b6412e1 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -10,13 +10,12 @@ export * as address from "./address.js"; export * as ast from "./ast/index.js"; export * as bip322 from "./bip322/index.js"; export * as inscriptions from "./inscriptions.js"; +export * as message from "./message.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"; - // Only the most commonly used classes and types are exported at the top level for convenience export { ECPair } from "./ecpair.js"; export { BIP32 } from "./bip32.js"; @@ -87,6 +86,11 @@ declare module "./wasm/wasm_utxo.js" { tapBip32Derivation: PsbtBip32Derivation[]; } + /** PSBT output data with resolved address, returned by getOutputsWithAddress() */ + interface PsbtOutputDataWithAddress extends PsbtOutputData { + address: string; + } + interface WrapPsbt { // Signing methods (legacy - kept for backwards compatibility) signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult; @@ -101,6 +105,7 @@ declare module "./wasm/wasm_utxo.js" { outputCount(): number; getInputs(): PsbtInputData[]; getOutputs(): PsbtOutputData[]; + getOutputsWithAddress(coin: import("./coinName.js").CoinName): PsbtOutputDataWithAddress[]; getPartialSignatures(inputIndex: number): Array<{ pubkey: Uint8Array; signature: Uint8Array; diff --git a/packages/wasm-utxo/js/message.ts b/packages/wasm-utxo/js/message.ts new file mode 100644 index 0000000..c649f3b --- /dev/null +++ b/packages/wasm-utxo/js/message.ts @@ -0,0 +1,46 @@ +/** + * Bitcoin message signing and verification (BIP-137) + * + * This module provides functions for signing and verifying Bitcoin messages + * using the standard Bitcoin Signed Message format (BIP-137). + * + * @example + * ```typescript + * import { message, ECPair } from '@bitgo/wasm-utxo'; + * + * // Sign a message + * const key = ECPair.fromWIF('L1TnU2zbNaAqMoVh65Cyvmcjzbrj41Gs9iTLcWbpJCpV1iNMXpuR'); + * const signature = message.signMessage('Hello, Bitcoin!', key); + * + * // Verify a message + * const isValid = message.verifyMessage('Hello, Bitcoin!', key, signature); + * ``` + */ + +import { MessageNamespace } from "./wasm/wasm_utxo.js"; +import { ECPair, type ECPairArg } from "./ecpair.js"; + +/** + * Sign a message using Bitcoin message signing (BIP-137) + * + * @param message - The message to sign + * @param key - The key to sign with (must have a private key) + * @returns 65-byte signature (1-byte header + 64-byte signature) + */ +export function signMessage(message: string, key: ECPairArg): Uint8Array { + const ecpair = ECPair.from(key); + return new Uint8Array(MessageNamespace.sign_message(ecpair.wasm, message)); +} + +/** + * Verify a Bitcoin message signature (BIP-137) + * + * @param message - The message that was signed + * @param key - The key to verify against + * @param signature - 65-byte signature (1-byte header + 64-byte signature) + * @returns True if the signature is valid for this key + */ +export function verifyMessage(message: string, key: ECPairArg, signature: Uint8Array): boolean { + const ecpair = ECPair.from(key); + return MessageNamespace.verify_message(ecpair.wasm, message, signature); +} diff --git a/packages/wasm-utxo/js/testutils/descriptor/descriptors.ts b/packages/wasm-utxo/js/testutils/descriptor/descriptors.ts new file mode 100644 index 0000000..461fc82 --- /dev/null +++ b/packages/wasm-utxo/js/testutils/descriptor/descriptors.ts @@ -0,0 +1,211 @@ +/** + * Descriptor test utilities for building common descriptor templates. + * Ported from @bitgo/utxo-core/testutil/descriptor/descriptors.ts. + */ +import assert from "assert"; + +import { BIP32 } from "../../bip32.js"; +import type { BIP32Interface } from "../../bip32.js"; +import { Descriptor, Miniscript, ast } from "../../index.js"; +import type { Triple } from "../../triple.js"; +import { DescriptorMap, PsbtParams } from "../../descriptorWallet/index.js"; +import { getKeyTriple } from "../keys.js"; + +type KeyTriple = Triple; + +export type DescriptorTemplate = + | "Wsh2Of3" + | "Tr1Of3-NoKeyPath-Tree" + // no xpubs, just plain keys + | "Tr1Of3-NoKeyPath-Tree-Plain" + | "Tr2Of3-NoKeyPath" + | "Wsh2Of2" + /** + * Wrapped segwit 2of3 multisig with a relative locktime OP_DROP + * (requiring a miniscript extension). Used in CoreDao staking transactions. + */ + | "Wsh2Of3CltvDrop"; + +/** + * Get the BIP-341 "Nothing Up My Sleeve" (NUMS) unspendable key. + * This is the x-only public key with unknown discrete logarithm + * constructed by hashing the uncompressed secp256k1 base point G. + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs + */ +export function getUnspendableKey(): string { + return "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; +} + +export function getDefaultXPubs(seed?: string): Triple { + return getKeyTriple(seed ?? "default").map((k) => k.neutered().toBase58()) as Triple; +} + +function toDescriptorMap(v: Record): DescriptorMap { + return new Map(Object.entries(v).map(([k, v]) => [k, Descriptor.fromString(v, "derivable")])); +} + +function toXPub(k: BIP32Interface | string, path: string): string { + if (typeof k === "string") { + return k + "/" + path; + } + return k.neutered().toBase58() + "/" + path; +} + +function toPlain(k: BIP32Interface | string, { xonly = false } = {}): string { + if (typeof k === "string") { + if (k.startsWith("xpub") || k.startsWith("xprv")) { + return toPlain(BIP32.fromBase58(k), { xonly }); + } + return k; + } + return toHex(k.publicKey.subarray(xonly ? 1 : 0)); +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function toXOnly(k: BIP32Interface | string): string { + return toPlain(k, { xonly: true }); +} + +function multiArgs( + m: number, + n: number, + keys: BIP32Interface[] | string[], + path: string, +): [number, ...string[]] { + if (n < m) { + throw new Error(`Cannot create ${m} of ${n} multisig`); + } + if (keys.length < n) { + throw new Error(`Not enough keys for ${m} of ${n} multisig: keys.length=${keys.length}`); + } + keys = keys.slice(0, n); + return [m, ...keys.map((k: BIP32Interface | string) => toXPub(k, path))]; +} + +export function getPsbtParams(t: DescriptorTemplate): Partial { + switch (t) { + case "Wsh2Of3CltvDrop": + return { locktime: 1 }; + default: + return {}; + } +} + +export function getDescriptorNode( + template: DescriptorTemplate, + keys: KeyTriple | string[] = getDefaultXPubs(), + path = "0/*", +): ast.DescriptorNode { + switch (template) { + case "Wsh2Of3": + return { + wsh: { multi: multiArgs(2, 3, keys, path) }, + }; + case "Wsh2Of3CltvDrop": { + const { locktime } = getPsbtParams(template); + assert(locktime); + return { + wsh: { + and_v: [{ "r:after": locktime }, { multi: multiArgs(2, 3, keys, path) }], + }, + }; + } + case "Wsh2Of2": + return { + wsh: { multi: multiArgs(2, 2, keys, path) }, + }; + case "Tr2Of3-NoKeyPath": + return { + tr: [getUnspendableKey(), { multi_a: multiArgs(2, 3, keys, path) }], + }; + case "Tr1Of3-NoKeyPath-Tree": + return { + tr: [ + getUnspendableKey(), + [ + { pk: toXPub(keys[0], path) }, + [{ pk: toXPub(keys[1], path) }, { pk: toXPub(keys[2], path) }], + ], + ], + }; + case "Tr1Of3-NoKeyPath-Tree-Plain": + return { + tr: [ + getUnspendableKey(), + [{ pk: toXOnly(keys[0]) }, [{ pk: toXOnly(keys[1]) }, { pk: toXOnly(keys[2]) }]], + ], + }; + } + throw new Error(`Unknown descriptor template: ${template as string}`); +} + +type TapTree = [TapTree, TapTree] | ast.MiniscriptNode; + +function getTapLeafScriptNodes(t: ast.DescriptorNode | TapTree): ast.MiniscriptNode[] { + if (Array.isArray(t)) { + if (t.length !== 2) { + throw new Error(`expected tuple, got: ${JSON.stringify(t)}`); + } + return t.map((v) => (Array.isArray(v) ? getTapLeafScriptNodes(v) : v)).flat(); + } + + if (typeof t === "object") { + const node = t; + if (!("tr" in node)) { + throw new Error( + `TapLeafScripts are only supported for Taproot descriptors, got: ${JSON.stringify(t)}`, + ); + } + if (!Array.isArray(node.tr) || node.tr.length !== 2) { + throw new Error(`expected tuple, got: ${JSON.stringify(node.tr)}`); + } + const tapscript = node.tr[1]; + if (!Array.isArray(tapscript)) { + throw new Error(`expected tapscript to be an array, got: ${JSON.stringify(tapscript)}`); + } + return getTapLeafScriptNodes(tapscript); + } + + throw new Error(`Invalid input: ${JSON.stringify(t)}`); +} + +export function containsKey( + script: Miniscript | ast.MiniscriptNode, + key: BIP32Interface | string, +): boolean { + if (script instanceof Miniscript) { + script = ast.fromMiniscript(script); + } + if ("pk" in script) { + return script.pk === toXOnly(key); + } + throw new Error(`Unsupported script type: ${JSON.stringify(script)}`); +} + +export function getTapLeafScripts(d: Descriptor): string[] { + return getTapLeafScriptNodes(ast.fromDescriptor(d)).map((n) => + Miniscript.fromString(ast.formatNode(n), "tap").toString(), + ); +} + +export function getDescriptor( + template: DescriptorTemplate, + keys: KeyTriple | string[] = getDefaultXPubs(), + path = "0/*", +): Descriptor { + return Descriptor.fromStringDetectType(ast.formatNode(getDescriptorNode(template, keys, path))); +} + +export function getDescriptorMap( + template: DescriptorTemplate, + keys: KeyTriple | string[] = getDefaultXPubs(), +): DescriptorMap { + return toDescriptorMap({ + external: getDescriptor(template, keys, "0/*").toString(), + internal: getDescriptor(template, keys, "1/*").toString(), + }); +} diff --git a/packages/wasm-utxo/js/testutils/descriptor/index.ts b/packages/wasm-utxo/js/testutils/descriptor/index.ts new file mode 100644 index 0000000..6cd9d33 --- /dev/null +++ b/packages/wasm-utxo/js/testutils/descriptor/index.ts @@ -0,0 +1,2 @@ +export * from "./descriptors.js"; +export * from "./mockPsbt.js"; diff --git a/packages/wasm-utxo/js/testutils/descriptor/mockPsbt.ts b/packages/wasm-utxo/js/testutils/descriptor/mockPsbt.ts new file mode 100644 index 0000000..4304650 --- /dev/null +++ b/packages/wasm-utxo/js/testutils/descriptor/mockPsbt.ts @@ -0,0 +1,137 @@ +/** + * Mock PSBT utilities for descriptor wallet testing. + * Ported from @bitgo/utxo-core/testutil/descriptor/mock.utils.ts. + * + * Key difference from utxo-core: returns wasm-utxo Psbt instances directly + * instead of utxolib.bitgo.UtxoPsbt. PsbtParams does not require a network field. + */ +import { Descriptor, Miniscript, Psbt } from "../../index.js"; +import { + PsbtParams, + DerivedDescriptorTransactionInput, + createPsbt, + createScriptPubKeyFromDescriptor, + WithOptDescriptor, + Output, +} from "../../descriptorWallet/index.js"; + +import { + DescriptorTemplate, + getDefaultXPubs, + getDescriptor, + getPsbtParams, +} from "./descriptors.js"; + +type MockOutputIdParams = { hash?: string; vout?: number }; + +type BaseMockDescriptorOutputParams = { + id?: MockOutputIdParams; + index?: number; + value?: bigint; + sequence?: number; + selectTapLeafScript?: Miniscript; +}; + +function mockOutputId(id?: MockOutputIdParams): { + hash: string; + vout: number; +} { + const hash = id?.hash ?? "0101010101010101010101010101010101010101010101010101010101010101"; + const vout = id?.vout ?? 0; + return { hash, vout }; +} + +export function mockDerivedDescriptorWalletOutput( + descriptor: Descriptor, + outputParams: BaseMockDescriptorOutputParams = {}, +): DerivedDescriptorTransactionInput { + const { value = BigInt(1e6) } = outputParams; + const { hash, vout } = mockOutputId(outputParams.id); + return { + hash, + index: vout, + witnessUtxo: { + script: createScriptPubKeyFromDescriptor(descriptor, undefined), + value, + }, + descriptor, + selectTapLeafScript: outputParams.selectTapLeafScript, + sequence: outputParams.sequence, + }; +} + +type MockInput = BaseMockDescriptorOutputParams & { + index: number; + descriptor: Descriptor; + selectTapLeafScript?: Miniscript; +}; + +type MockOutput = { + descriptor: Descriptor; + index: number; + value: bigint; + external?: boolean; +}; + +function tryDeriveAtIndex(descriptor: Descriptor, index: number): Descriptor { + return descriptor.hasWildcard() ? descriptor.atDerivationIndex(index) : descriptor; +} + +export function mockPsbt( + inputs: MockInput[], + outputs: MockOutput[], + params: Partial = {}, +): Psbt { + return createPsbt( + params, + inputs.map((i) => + mockDerivedDescriptorWalletOutput(tryDeriveAtIndex(i.descriptor, i.index), i), + ), + outputs.map((o): WithOptDescriptor => { + const derivedDescriptor = tryDeriveAtIndex(o.descriptor, o.index); + return { + script: createScriptPubKeyFromDescriptor(derivedDescriptor, undefined), + value: o.value, + descriptor: o.external ? undefined : derivedDescriptor, + }; + }), + ); +} + +export function mockPsbtDefault({ + descriptorSelf = getDescriptor("Wsh2Of3", getDefaultXPubs("a")), + descriptorOther = getDescriptor("Wsh2Of3", getDefaultXPubs("b")), + params = {}, +}: { + descriptorSelf?: Descriptor; + descriptorOther?: Descriptor; + params?: Partial; +} = {}): Psbt { + return mockPsbt( + [ + { descriptor: descriptorSelf, index: 0 }, + { descriptor: descriptorSelf, index: 1, id: { vout: 1 } }, + ], + [ + { + descriptor: descriptorOther, + index: 0, + value: BigInt(4e5), + external: true, + }, + { descriptor: descriptorSelf, index: 0, value: BigInt(4e5) }, + ], + params, + ); +} + +export function mockPsbtDefaultWithDescriptorTemplate( + t: DescriptorTemplate, + params: Partial = {}, +): Psbt { + return mockPsbtDefault({ + descriptorSelf: getDescriptor(t, getDefaultXPubs("a")), + descriptorOther: getDescriptor(t, getDefaultXPubs("b")), + params: { ...getPsbtParams(t), ...params }, + }); +} diff --git a/packages/wasm-utxo/js/testutils/fixtures.ts b/packages/wasm-utxo/js/testutils/fixtures.ts new file mode 100644 index 0000000..9fb66a5 --- /dev/null +++ b/packages/wasm-utxo/js/testutils/fixtures.ts @@ -0,0 +1,258 @@ +/// +/** + * Generic test fixture and serialization utilities. + * Ported from @bitgo/utxo-core/testutil/fixtures.utils.ts + * and @bitgo/utxo-core/testutil/toPlainObject.utils.ts. + * + * NOTE: getFixture requires Node.js (fs, process.env). + * The toPlainObject / jsonNormalize utilities are platform-independent. + */ +import * as fs from "fs"; +import * as mpath from "path"; + +// ===== getFixture / jsonNormalize ===== + +type FixtureEncoding = "json" | "hex" | "txt"; + +function isNodeJsError(e: unknown): e is NodeJS.ErrnoException { + return e instanceof Error && typeof (e as NodeJS.ErrnoException).code === "string"; +} + +function fixtureEncoding(path: string): FixtureEncoding { + if (path.endsWith(".json")) { + return "json"; + } + if (path.endsWith(".hex")) { + return "hex"; + } + if (path.endsWith(".txt")) { + return "txt"; + } + throw new Error(`unknown fixture encoding for ${path}`); +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function decodeFixture(raw: string, encoding: FixtureEncoding): unknown { + switch (encoding) { + case "json": + return JSON.parse(raw); + case "hex": + return hexToBytes(raw); + case "txt": + return raw; + } +} + +function encodeFixture(value: unknown, encoding: FixtureEncoding): string { + switch (encoding) { + case "json": + return JSON.stringify(value, null, 2) + "\n"; + case "hex": + if (!(value instanceof Uint8Array)) { + throw new Error(`expected Uint8Array, got ${typeof value}`); + } + return bytesToHex(value); + case "txt": + if (typeof value !== "string") { + throw new Error(`expected string, got ${typeof value}`); + } + return value; + } +} + +/** + * Return fixture described in `path`. + * + * If the file does not exist and `defaultValue` is provided, + * writes defaultValue to `path` and throws an error prompting + * the developer to inspect and re-run. + * + * @param path - Path to the fixture file (.json, .hex, or .txt) + * @param defaultValue - Default value to write if fixture is missing + * @returns The fixture content + */ +export async function getFixture( + path: string, + defaultValue?: T | (() => Promise), +): Promise { + try { + await fs.promises.stat(mpath.dirname(path)); + } catch (e) { + if (isNodeJsError(e) && e.code === "ENOENT") { + throw new Error(`fixture directory ${mpath.dirname(path)} not found, please create it first`); + } + throw e; + } + + const encoding = fixtureEncoding(path); + + try { + return decodeFixture(await fs.promises.readFile(path, "utf8"), encoding) as T; + } catch (e) { + if (isNodeJsError(e) && e.code === "ENOENT") { + // eslint-disable-next-line no-restricted-globals + if (process.env.WRITE_FIXTURES === "0") { + throw new Error(`fixture ${path} not found, WRITE_FIXTURES=0`); + } + if (defaultValue === undefined) { + throw new Error(`fixture ${path} not found and no default value given`); + } + if (typeof defaultValue === "function") { + defaultValue = await (defaultValue as () => Promise)(); + } + await fs.promises.writeFile(path, encodeFixture(defaultValue, encoding)); + throw new Error(`wrote default value for ${path}, please inspect and restart test`); + } + + throw e; + } +} + +/** + * JSON round-trip normalization. + * Converts a value to JSON and back, stripping non-serializable properties + * and normalizing types (e.g. undefined -> null in arrays). + */ +export function jsonNormalize(v: T): T { + return JSON.parse(JSON.stringify(v)) as T; +} + +// ===== toPlainObject ===== + +export type ToPlainObjectOpts = { + propertyDescriptors?: boolean; + skipUndefinedValues?: boolean; + ignorePaths?: string[] | ((path: PathElement[]) => boolean); + apply?: (v: unknown, path: PathElement[]) => unknown; +}; + +export type PathElement = string | number; + +export function matchPath(a: PathElement[], b: PathElement[]): boolean { + return a.length === b.length && a.every((e, i) => e === b[i]); +} + +function includePath(opts: ToPlainObjectOpts, path: PathElement[]): boolean { + if (!opts.ignorePaths) { + return true; + } + if (typeof opts.ignorePaths === "function") { + return !opts.ignorePaths(path); + } + return !opts.ignorePaths.some((ignorePath) => matchPath(path, ignorePath.split("."))); +} + +function toPlainEntries( + key: string, + value: unknown, + opts: ToPlainObjectOpts, + path: PathElement[], +): [] | [[string, unknown]] { + if (!includePath(opts, [...path, key])) { + return []; + } + if (value === undefined && (opts.skipUndefinedValues ?? true)) { + return []; + } + return [[key, toPlainObject(value, opts, [...path, key])]]; +} + +function getAllPropertyDescriptors(v: unknown): PropertyDescriptorMap { + if (v === null || typeof v !== "object") { + return {}; + } + const descriptors: PropertyDescriptorMap = Object.getOwnPropertyDescriptors(v); + const proto: unknown = Object.getPrototypeOf(v); + if (proto) { + Object.assign(descriptors, getAllPropertyDescriptors(proto)); + } + return descriptors; +} + +function toPlainObjectFromPropertyDescriptors( + v: unknown, + opts: ToPlainObjectOpts, + path: PathElement[], +) { + const descriptors = getAllPropertyDescriptors(v); + return Object.fromEntries( + Object.entries(descriptors).flatMap(([key, descriptor]) => { + if (typeof descriptor.value === "function") { + return []; + } + if (descriptor.value !== undefined) { + return toPlainEntries(key, descriptor.value, opts, path); + } + if (typeof descriptor.get === "function") { + return toPlainEntries(key, descriptor.get.call(v), opts, path); + } + return []; + }), + ); +} + +/** + * Recursively convert a value to a plain JSON-serializable object. + * Handles Uint8Array (to hex), bigint (to string), Buffer, and more. + * + * @param v - The value to convert + * @param opts - Options for customizing the conversion + * @param path - Current property path (used for ignorePaths) + */ +export function toPlainObject( + v: unknown, + opts: ToPlainObjectOpts = {}, + path: PathElement[] = [], +): unknown { + if (opts.apply) { + const result = opts.apply(v, path); + if (result !== undefined) { + return result; + } + } + + switch (typeof v) { + case "string": + case "number": + case "boolean": + case "undefined": + return v; + case "bigint": + return v.toString(); + case "function": + case "symbol": + return undefined; + } + + if (v === null) { + return v; + } + + if (v instanceof Uint8Array) { + return Array.from(v, (b) => b.toString(16).padStart(2, "0")).join(""); + } + if (Array.isArray(v)) { + return v.map((e, i) => toPlainObject(e, opts, [...path, i])); + } + if (typeof v === "object") { + const result = Object.fromEntries( + Object.entries(v).flatMap(([key, value]) => toPlainEntries(key, value, opts, path)), + ); + if (opts.propertyDescriptors) { + Object.assign(result, toPlainObjectFromPropertyDescriptors(v, opts, path)); + } + return result; + } + throw new Error(`unknown v ${typeof v}`); +} diff --git a/packages/wasm-utxo/js/testutils/index.ts b/packages/wasm-utxo/js/testutils/index.ts index ca57a07..6ed29a7 100644 --- a/packages/wasm-utxo/js/testutils/index.ts +++ b/packages/wasm-utxo/js/testutils/index.ts @@ -1,2 +1,4 @@ export * from "./keys.js"; export * from "./AcidTest.js"; +export * from "./fixtures.js"; +export * as descriptor from "./descriptor/index.js"; diff --git a/packages/wasm-utxo/package.json b/packages/wasm-utxo/package.json index 94b8b02..40aad90 100644 --- a/packages/wasm-utxo/package.json +++ b/packages/wasm-utxo/package.json @@ -23,6 +23,16 @@ "types": "./dist/cjs/js/index.d.ts", "default": "./dist/cjs/js/index.js" } + }, + "./testutils": { + "import": { + "types": "./dist/esm/js/testutils/index.d.ts", + "default": "./dist/esm/js/testutils/index.js" + }, + "require": { + "types": "./dist/cjs/js/testutils/index.d.ts", + "default": "./dist/cjs/js/testutils/index.js" + } } }, "main": "./dist/cjs/js/index.js", diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index 9aa52a0..f3553b1 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -4,6 +4,7 @@ pub mod dash; mod error; pub mod fixed_script_wallet; pub mod inscriptions; +pub mod message; mod networks; pub mod paygo; #[cfg(test)] diff --git a/packages/wasm-utxo/src/message.rs b/packages/wasm-utxo/src/message.rs new file mode 100644 index 0000000..51dc0dc --- /dev/null +++ b/packages/wasm-utxo/src/message.rs @@ -0,0 +1,167 @@ +//! Bitcoin message signing and verification (BIP-137) + +use crate::bitcoin::{ + consensus::Encodable, + hashes::{sha256d, Hash}, + secp256k1::{self, PublicKey, Secp256k1, SecretKey}, + VarInt, +}; +use crate::error::WasmUtxoError; + +/// Bitcoin message signing prefix +const BITCOIN_SIGNED_MESSAGE_PREFIX: &[u8] = b"\x18Bitcoin Signed Message:\n"; + +/// Compute Bitcoin message hash (double SHA256 with magic prefix) +fn bitcoin_message_hash(message: &str) -> sha256d::Hash { + let message_bytes = message.as_bytes(); + + // Build the full message: magic + varint(len) + message + let mut data = Vec::new(); + data.extend_from_slice(BITCOIN_SIGNED_MESSAGE_PREFIX); + + let varint = VarInt::from(message_bytes.len()); + let mut varint_bytes = Vec::new(); + // consensus_encode on VarInt to Vec is infallible + varint.consensus_encode(&mut varint_bytes).unwrap(); + data.extend_from_slice(&varint_bytes); + + data.extend_from_slice(message_bytes); + + sha256d::Hash::hash(&data) +} + +/// Sign a message using Bitcoin message signing (BIP-137) +/// +/// Returns 65-byte recoverable signature (1-byte header + 64-byte signature). +/// Header = 31 + recovery_id (always compressed keys). +pub fn sign_bitcoin_message( + secret_key: &SecretKey, + message: &str, +) -> Result, WasmUtxoError> { + let message_hash = bitcoin_message_hash(message); + let msg = secp256k1::Message::from_digest(*message_hash.as_ref()); + + let secp = Secp256k1::signing_only(); + let recoverable_sig = secp.sign_ecdsa_recoverable(&msg, secret_key); + let (recovery_id, compact_sig) = recoverable_sig.serialize_compact(); + + // BIP-137 format: 1-byte header + 64-byte signature + // Header: 27 + recovery_id + (compressed ? 4 : 0) + // We always use compressed keys, so header = 31 + recovery_id + let header = 31 + recovery_id.to_i32() as u8; + + let mut sig_bytes = Vec::with_capacity(65); + sig_bytes.push(header); + sig_bytes.extend_from_slice(&compact_sig); + + Ok(sig_bytes) +} + +/// Verify a Bitcoin message signature (BIP-137) +/// +/// Recovers the public key from the 65-byte signature and compares it to the +/// provided public key. Returns `true` if they match. +pub fn verify_bitcoin_message( + public_key: &PublicKey, + message: &str, + signature: &[u8], +) -> Result { + if signature.len() != 65 { + return Err(WasmUtxoError::new("Signature must be 65 bytes")); + } + + let recovery_flags = signature[0]; + let compact_sig = &signature[1..65]; + + // Decode recovery ID from flags + // Compressed keys: 31-34 (recid 0-3), Uncompressed: 27-30 + let recovery_id = if (31..=34).contains(&recovery_flags) { + secp256k1::ecdsa::RecoveryId::from_i32((recovery_flags - 31) as i32) + .map_err(|e| WasmUtxoError::new(&format!("Invalid recovery ID: {}", e)))? + } else if (27..=30).contains(&recovery_flags) { + secp256k1::ecdsa::RecoveryId::from_i32((recovery_flags - 27) as i32) + .map_err(|e| WasmUtxoError::new(&format!("Invalid recovery ID: {}", e)))? + } else { + return Err(WasmUtxoError::new(&format!( + "Invalid signature header: {}", + recovery_flags + ))); + }; + + let recoverable_sig = + secp256k1::ecdsa::RecoverableSignature::from_compact(compact_sig, recovery_id) + .map_err(|e| WasmUtxoError::new(&format!("Invalid signature format: {}", e)))?; + + let message_hash = bitcoin_message_hash(message); + let msg = secp256k1::Message::from_digest(*message_hash.as_ref()); + + let secp = Secp256k1::verification_only(); + let recovered_pubkey = secp + .recover_ecdsa(&msg, &recoverable_sig) + .map_err(|e| WasmUtxoError::new(&format!("Failed to recover public key: {}", e)))?; + + Ok(&recovered_pubkey == public_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sign_and_verify_roundtrip() { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).expect("32 bytes, within curve order"); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + let message = "Hello, Bitcoin!"; + let signature = sign_bitcoin_message(&secret_key, message).unwrap(); + + assert_eq!(signature.len(), 65); + assert!(verify_bitcoin_message(&public_key, message, &signature).unwrap()); + } + + #[test] + fn test_verify_wrong_key() { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).expect("32 bytes, within curve order"); + let wrong_secret_key = + SecretKey::from_slice(&[0x02; 32]).expect("32 bytes, within curve order"); + let wrong_public_key = PublicKey::from_secret_key(&secp, &wrong_secret_key); + + let message = "Hello, Bitcoin!"; + let signature = sign_bitcoin_message(&secret_key, message).unwrap(); + + assert!(!verify_bitcoin_message(&wrong_public_key, message, &signature).unwrap()); + } + + #[test] + fn test_verify_wrong_message() { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).expect("32 bytes, within curve order"); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + let signature = sign_bitcoin_message(&secret_key, "original message").unwrap(); + + assert!(!verify_bitcoin_message(&public_key, "different message", &signature).unwrap()); + } + + #[test] + fn test_verify_invalid_signature_length() { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).expect("32 bytes, within curve order"); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + let result = verify_bitcoin_message(&public_key, "test", &[0u8; 32]); + assert!(result.is_err()); + } + + #[test] + fn test_message_hash_deterministic() { + let hash1 = bitcoin_message_hash("test message"); + let hash2 = bitcoin_message_hash("test message"); + assert_eq!(hash1, hash2); + + let hash3 = bitcoin_message_hash("different message"); + assert_ne!(hash1, hash3); + } +} diff --git a/packages/wasm-utxo/src/wasm/bip32.rs b/packages/wasm-utxo/src/wasm/bip32.rs index 9a05a94..2d6cc7f 100644 --- a/packages/wasm-utxo/src/wasm/bip32.rs +++ b/packages/wasm-utxo/src/wasm/bip32.rs @@ -9,7 +9,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; // Internal enum to hold either Xpub or Xpriv -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] enum BIP32Key { Public(Xpub), Private(Xpriv), @@ -353,6 +353,14 @@ impl WasmBIP32 { pub fn derive_path(&self, path: &str) -> Result { Ok(WasmBIP32(self.0.derive_path(path)?)) } + + /// Check equality with another WasmBIP32 key. + /// Two keys are equal if they have the same type (public/private) and identical + /// BIP32 metadata (depth, parent fingerprint, child index, chain code, key data). + #[wasm_bindgen] + pub fn equals(&self, other: &WasmBIP32) -> bool { + self.0 == other.0 + } } // Non-WASM methods for internal use diff --git a/packages/wasm-utxo/src/wasm/message.rs b/packages/wasm-utxo/src/wasm/message.rs new file mode 100644 index 0000000..a96ad7a --- /dev/null +++ b/packages/wasm-utxo/src/wasm/message.rs @@ -0,0 +1,38 @@ +use crate::error::WasmUtxoError; +use crate::message; +use crate::wasm::ecpair::WasmECPair; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub struct MessageNamespace; + +#[wasm_bindgen] +impl MessageNamespace { + /// Sign a message using Bitcoin message signing (BIP-137) + /// + /// Returns 65-byte signature (1-byte header + 64-byte signature). + /// The key must have a private key (cannot sign with public key only). + #[wasm_bindgen] + pub fn sign_message( + key: &WasmECPair, + message_str: &str, + ) -> Result { + let secret_key = key.get_private_key()?; + let signature = message::sign_bitcoin_message(&secret_key, message_str)?; + Ok(js_sys::Uint8Array::from(&signature[..])) + } + + /// Verify a Bitcoin message signature (BIP-137) + /// + /// Signature must be 65 bytes (1-byte header + 64-byte signature). + /// Returns true if the signature is valid for this key. + #[wasm_bindgen] + pub fn verify_message( + key: &WasmECPair, + message_str: &str, + signature: &[u8], + ) -> Result { + let public_key = key.get_public_key(); + message::verify_bitcoin_message(&public_key, message_str, signature) + } +} diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index a0dc94b..1d6edac 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -6,6 +6,7 @@ mod descriptor; mod ecpair; mod fixed_script_wallet; mod inscriptions; +mod message; mod miniscript; mod psbt; mod recursive_tap_tree; @@ -24,6 +25,7 @@ pub use descriptor::WrapDescriptor; pub use ecpair::WasmECPair; pub use fixed_script_wallet::{BitGoPsbt, FixedScriptWalletNamespace, WasmDimensions}; pub use inscriptions::InscriptionsNamespace; +pub use message::MessageNamespace; pub use miniscript::WrapMiniscript; pub use psbt::WrapPsbt; pub use replay_protection::WasmReplayProtection; diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index 56ac8e2..f03cbf4 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -1,3 +1,4 @@ +use crate::address::networks::{from_output_script_with_network_and_format, AddressFormat}; use crate::error::WasmUtxoError; use crate::wasm::bip32::WasmBIP32; use crate::wasm::descriptor::WrapDescriptorEnum; @@ -11,7 +12,7 @@ use miniscript::bitcoin::transaction::{Transaction, Version}; use miniscript::bitcoin::{ bip32, psbt, Amount, OutPoint, PublicKey, ScriptBuf, Sequence, XOnlyPublicKey, }; -use miniscript::bitcoin::{PrivateKey, Psbt, TxIn, TxOut, Txid}; +use miniscript::bitcoin::{PrivateKey, Psbt, Script, TxIn, TxOut, Txid}; use miniscript::descriptor::{SinglePub, SinglePubKey}; use miniscript::psbt::PsbtExt; use miniscript::{DescriptorPublicKey, ToPublicKey}; @@ -176,6 +177,32 @@ impl PsbtOutputData { } } +/// PSBT output data with a resolved address string (requires a coin name for encoding). +#[derive(Debug, Clone)] +pub struct PsbtOutputDataWithAddress { + pub script: Vec, + pub value: u64, + pub address: String, + pub bip32_derivation: Vec, + pub tap_bip32_derivation: Vec, +} + +impl PsbtOutputDataWithAddress { + pub fn from(base: PsbtOutputData, network: crate::Network) -> Result { + let script_obj = Script::from_bytes(&base.script); + let address = + from_output_script_with_network_and_format(script_obj, network, AddressFormat::Default) + .map_err(|e| WasmUtxoError::new(&e.to_string()))?; + Ok(PsbtOutputDataWithAddress { + script: base.script, + value: base.value, + address, + bip32_derivation: base.bip32_derivation, + tap_bip32_derivation: base.tap_bip32_derivation, + }) + } +} + #[wasm_bindgen] pub struct WrapPsbt(Psbt); @@ -579,6 +606,28 @@ impl WrapPsbt { outputs.try_to_js_value() } + /// Get all PSBT outputs with resolved address strings. + /// + /// Like `getOutputs()` but each element also includes an `address` field + /// derived from the output script using the given coin name (e.g. "btc", "tbtc"). + #[wasm_bindgen(js_name = getOutputsWithAddress)] + pub fn get_outputs_with_address(&self, coin: &str) -> Result { + let network = crate::Network::from_coin_name(coin) + .ok_or_else(|| WasmUtxoError::new(&format!("Unknown coin: {}", coin)))?; + let outputs: Vec = self + .0 + .unsigned_tx + .output + .iter() + .zip(self.0.outputs.iter()) + .map(|(tx_out, psbt_out)| { + let base = PsbtOutputData::from(tx_out, psbt_out); + PsbtOutputDataWithAddress::from(base, network) + }) + .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 8858cef..0134a0f 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -447,6 +447,18 @@ impl TryIntoJsValue for crate::wasm::psbt::PsbtOutputData { } } +impl TryIntoJsValue for crate::wasm::psbt::PsbtOutputDataWithAddress { + fn try_to_js_value(&self) -> Result { + js_obj!( + "script" => self.script.clone(), + "value" => self.value, + "address" => self.address.clone(), + "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/bip32.ts b/packages/wasm-utxo/test/bip32.ts index a9257c1..bb9b15c 100644 --- a/packages/wasm-utxo/test/bip32.ts +++ b/packages/wasm-utxo/test/bip32.ts @@ -159,6 +159,95 @@ describe("WasmBIP32", () => { }); }); +describe("BIP32.equals", () => { + it("should return true for identical keys from same base58", () => { + const a = BIP32.fromBase58(XPUB); + const b = BIP32.fromBase58(XPUB); + assert.ok(a.equals(b)); + assert.ok(b.equals(a)); + }); + + it("should return true for identical private keys", () => { + const a = BIP32.fromBase58(XPRV); + const b = BIP32.fromBase58(XPRV); + assert.ok(a.equals(b)); + }); + + it("should return false for different keys", () => { + const a = BIP32.fromBase58(XPRV); + const b = a.derive(0); + assert.ok(!a.equals(b)); + assert.ok(!b.equals(a)); + }); + + it("should return false for private key vs its neutered form", () => { + const priv = BIP32.fromBase58(XPRV); + const pub_ = priv.neutered(); + assert.ok(!priv.equals(pub_)); + assert.ok(!pub_.equals(priv)); + }); + + it("should return true for neutered keys derived from same private key", () => { + const a = BIP32.fromBase58(XPRV).neutered(); + const b = BIP32.fromBase58(XPRV).neutered(); + assert.ok(a.equals(b)); + }); + + it("should return true for derived keys at same path", () => { + const root = BIP32.fromBase58(XPRV); + const a = root.derivePath("0/1/2").neutered(); + const b = root.derivePath("0/1/2").neutered(); + assert.ok(a.equals(b)); + }); + + it("should work with BIP32Interface from utxolib", () => { + const wasmKey = BIP32.fromBase58(XPUB); + const utxolibKey = utxolibBip32.fromBase58(XPUB); + assert.ok(wasmKey.equals(utxolibKey)); + }); +}); + +describe("BIP32.toJSON and inspect", () => { + it("should return xpub and hasPrivateKey=false from toJSON for public key", () => { + const key = BIP32.fromBase58(XPUB); + assert.deepStrictEqual(key.toJSON(), { xpub: XPUB, hasPrivateKey: false }); + }); + + it("should return xpub and hasPrivateKey=true from toJSON for private key", () => { + const key = BIP32.fromBase58(XPRV); + const json = key.toJSON(); + assert.strictEqual(json.hasPrivateKey, true); + assert.ok(json.xpub.startsWith("xpub"), "should serialize as xpub, not xprv"); + assert.strictEqual(json.xpub, key.neutered().toBase58()); + }); + + it("should never leak xprv in JSON.stringify", () => { + const key = BIP32.fromBase58(XPRV); + const serialized = JSON.stringify({ key }); + assert.ok(!serialized.includes("xprv"), "serialized JSON must not contain xprv"); + assert.ok(serialized.includes("xpub"), "serialized JSON must contain xpub"); + }); + + it("should return formatted string from inspect for public key", () => { + const key = BIP32.fromBase58(XPUB); + const inspect = key[ + Symbol.for("nodejs.util.inspect.custom") as unknown as symbol + ] as () => string; + assert.strictEqual(inspect.call(key), `BIP32(${XPUB})`); + }); + + it("should return formatted string with flag from inspect for private key", () => { + const key = BIP32.fromBase58(XPRV); + const inspect = key[ + Symbol.for("nodejs.util.inspect.custom") as unknown as symbol + ] as () => string; + const result = inspect.call(key) as string; + assert.ok(result.includes("hasPrivateKey"), "should indicate private key presence"); + assert.ok(!result.includes("xprv"), "should not leak xprv"); + assert.ok(result.startsWith("BIP32(xpub"), "should show xpub"); + }); +}); + describe("WasmBIP32 parity with utxolib", () => { it("should match utxolib when creating from base58 xpub", () => { assertKeysMatch(BIP32.fromBase58(XPUB), utxolibBip32.fromBase58(XPUB)); diff --git a/packages/wasm-utxo/test/message.ts b/packages/wasm-utxo/test/message.ts new file mode 100644 index 0000000..d87e196 --- /dev/null +++ b/packages/wasm-utxo/test/message.ts @@ -0,0 +1,173 @@ +import * as assert from "assert"; +import { ECPair } from "../js/ecpair.js"; +import { message } from "../js/index.js"; + +describe("Message Signing (BIP-137)", () => { + const testPrivateKey = new Uint8Array( + Buffer.from("1111111111111111111111111111111111111111111111111111111111111111", "hex"), + ); + + describe("signMessage", () => { + it("should sign a message and return 65-byte signature", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + assert.ok(signature instanceof Uint8Array); + assert.strictEqual(signature.length, 65); + }); + + it("should produce a valid BIP-137 header byte", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + // Compressed key headers: 31-34 (31 + recovery_id 0-3) + assert.ok( + signature[0] >= 31 && signature[0] <= 34, + `Expected header 31-34, got ${signature[0]}`, + ); + }); + + it("should fail to sign with public key only", () => { + const privateKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = ECPair.fromPublicKey(privateKey.publicKey); + + assert.throws(() => { + message.signMessage("Hello, Bitcoin!", publicKey); + }); + }); + + it("should accept ECPairArg as Uint8Array (private key)", () => { + const signature = message.signMessage("Hello, Bitcoin!", testPrivateKey); + + assert.ok(signature instanceof Uint8Array); + assert.strictEqual(signature.length, 65); + }); + + it("should accept ECPairArg as WasmECPair", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key.wasm); + + assert.ok(signature instanceof Uint8Array); + assert.strictEqual(signature.length, 65); + }); + }); + + describe("verifyMessage", () => { + it("should verify a valid signature", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + assert.strictEqual(message.verifyMessage("Hello, Bitcoin!", key, signature), true); + }); + + it("should verify with public key only", () => { + const privateKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = ECPair.fromPublicKey(privateKey.publicKey); + + const signature = message.signMessage("Hello, Bitcoin!", privateKey); + + assert.strictEqual(message.verifyMessage("Hello, Bitcoin!", publicKey, signature), true); + }); + + it("should reject signature for different message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + assert.strictEqual(message.verifyMessage("Different message", key, signature), false); + }); + + it("should reject signature for wrong key", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const key2 = ECPair.fromPrivateKey( + new Uint8Array( + Buffer.from("2222222222222222222222222222222222222222222222222222222222222222", "hex"), + ), + ); + + const signature = message.signMessage("Hello, Bitcoin!", key1); + + assert.strictEqual(message.verifyMessage("Hello, Bitcoin!", key2, signature), false); + }); + + it("should reject signature of invalid length", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + + assert.throws(() => { + message.verifyMessage("test", key, new Uint8Array(32)); + }); + + assert.throws(() => { + message.verifyMessage("test", key, new Uint8Array(64)); + }); + }); + + it("should accept ECPairArg as Uint8Array (public key) for verification", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + assert.strictEqual(message.verifyMessage("Hello, Bitcoin!", key.publicKey, signature), true); + }); + }); + + describe("edge cases", () => { + it("should handle empty message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("", key); + + assert.strictEqual(signature.length, 65); + assert.strictEqual(message.verifyMessage("", key, signature), true); + }); + + it("should handle long message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const msg = "A".repeat(1000); + const signature = message.signMessage(msg, key); + + assert.strictEqual(message.verifyMessage(msg, key, signature), true); + }); + + it("should handle unicode message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const msg = "Hello, δΈ–η•Œ! πŸš€"; + const signature = message.signMessage(msg, key); + + assert.strictEqual(message.verifyMessage(msg, key, signature), true); + }); + + it("should produce consistent signatures", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const sig1 = message.signMessage("Test message", key); + const sig2 = message.signMessage("Test message", key); + + assert.strictEqual(message.verifyMessage("Test message", key, sig1), true); + assert.strictEqual(message.verifyMessage("Test message", key, sig2), true); + }); + }); + + describe("cross-verification with wasm-bip32", () => { + // Use a known private key and message to produce a deterministic signature + // that can be verified across implementations + it("should produce signatures verifiable by the same key's public key", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const publicOnlyKey = ECPair.fromPublicKey(key.publicKey); + + const testMessages = [ + "Hello, Bitcoin!", + "", + "The quick brown fox jumps over the lazy dog", + "0", + "A".repeat(253), // just below varint boundary + "A".repeat(254), // at varint boundary + ]; + + for (const msg of testMessages) { + const signature = message.signMessage(msg, key); + assert.strictEqual( + message.verifyMessage(msg, publicOnlyKey, signature), + true, + `Failed to verify message: ${JSON.stringify(msg.slice(0, 50))}`, + ); + } + }); + }); +}); diff --git a/packages/wasm-utxo/test/testutilsDescriptor.ts b/packages/wasm-utxo/test/testutilsDescriptor.ts new file mode 100644 index 0000000..c7d8068 --- /dev/null +++ b/packages/wasm-utxo/test/testutilsDescriptor.ts @@ -0,0 +1,217 @@ +import * as assert from "node:assert"; + +import { Descriptor, Psbt, address } from "../js/index.js"; +import * as dt from "../js/testutils/descriptor/index.js"; +import * as testutils from "../js/testutils/index.js"; + +describe("testutils.descriptor", () => { + describe("getDefaultXPubs", () => { + it("returns triple of xpub strings", () => { + const xpubs = dt.getDefaultXPubs(); + assert.strictEqual(xpubs.length, 3); + for (const xpub of xpubs) { + assert.ok(xpub.startsWith("xpub"), `expected xpub, got ${xpub.slice(0, 10)}`); + } + }); + + it("returns deterministic xpubs for same seed", () => { + assert.deepStrictEqual(dt.getDefaultXPubs("foo"), dt.getDefaultXPubs("foo")); + }); + + it("returns different xpubs for different seeds", () => { + const a = dt.getDefaultXPubs("a"); + const b = dt.getDefaultXPubs("b"); + assert.notDeepStrictEqual(a, b); + }); + }); + + describe("getUnspendableKey", () => { + it("returns the BIP-341 NUMS point", () => { + const key = dt.getUnspendableKey(); + assert.strictEqual(key.length, 64); + assert.strictEqual(key, "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"); + }); + }); + + describe("getDescriptor", () => { + const templates: dt.DescriptorTemplate[] = [ + "Wsh2Of3", + "Wsh2Of2", + "Tr1Of3-NoKeyPath-Tree", + "Tr1Of3-NoKeyPath-Tree-Plain", + "Tr2Of3-NoKeyPath", + "Wsh2Of3CltvDrop", + ]; + + for (const t of templates) { + it(`creates valid descriptor for ${t}`, () => { + const d = dt.getDescriptor(t); + assert.ok(d instanceof Descriptor); + const str = d.toString(); + assert.ok(str.length > 0, "descriptor string should not be empty"); + }); + } + }); + + describe("getDescriptorMap", () => { + it("returns map with external and internal keys", () => { + const map = dt.getDescriptorMap("Wsh2Of3"); + assert.ok(map.has("external")); + assert.ok(map.has("internal")); + assert.ok(map.get("external") instanceof Descriptor); + assert.ok(map.get("internal") instanceof Descriptor); + assert.notStrictEqual(map.get("external")?.toString(), map.get("internal")?.toString()); + }); + }); + + describe("getPsbtParams", () => { + it("returns locktime for Wsh2Of3CltvDrop", () => { + assert.deepStrictEqual(dt.getPsbtParams("Wsh2Of3CltvDrop"), { locktime: 1 }); + }); + + it("returns empty for Wsh2Of3", () => { + assert.deepStrictEqual(dt.getPsbtParams("Wsh2Of3"), {}); + }); + }); +}); + +describe("testutils.descriptor.mockPsbt", () => { + describe("mockPsbtDefault", () => { + it("creates a valid Psbt", () => { + const psbt = dt.mockPsbtDefault(); + assert.ok(psbt instanceof Psbt); + assert.strictEqual(psbt.inputCount(), 2); + assert.strictEqual(psbt.outputCount(), 2); + }); + }); + + describe("mockPsbtDefaultWithDescriptorTemplate", () => { + for (const t of ["Wsh2Of3", "Wsh2Of2", "Wsh2Of3CltvDrop"] as dt.DescriptorTemplate[]) { + it(`creates valid Psbt for ${t}`, () => { + const psbt = dt.mockPsbtDefaultWithDescriptorTemplate(t); + assert.ok(psbt instanceof Psbt); + assert.strictEqual(psbt.inputCount(), 2); + assert.strictEqual(psbt.outputCount(), 2); + }); + } + }); +}); + +describe("Psbt.getOutputsWithAddress", () => { + it("returns outputs with address strings for btc", () => { + const psbt = dt.mockPsbtDefault(); + const outputs = psbt.getOutputsWithAddress("btc"); + assert.strictEqual(outputs.length, 2); + for (const output of outputs) { + assert.ok(typeof output.address === "string", "address should be a string"); + assert.ok(output.address.length > 0, "address should not be empty"); + assert.ok(output.script instanceof Uint8Array, "script should be Uint8Array"); + assert.ok(typeof output.value === "bigint", "value should be bigint"); + assert.ok(Array.isArray(output.bip32Derivation), "bip32Derivation should be array"); + assert.ok(Array.isArray(output.tapBip32Derivation), "tapBip32Derivation should be array"); + } + }); + + it("returns consistent addresses with address.fromOutputScriptWithCoin", () => { + const psbt = dt.mockPsbtDefault(); + const outputsWithAddr = psbt.getOutputsWithAddress("btc"); + const rawOutputs = psbt.getOutputs(); + for (let i = 0; i < rawOutputs.length; i++) { + const expected = address.fromOutputScriptWithCoin(rawOutputs[i].script, "btc"); + assert.strictEqual(outputsWithAddr[i].address, expected); + } + }); + + it("returns btc mainnet addresses starting with expected prefixes", () => { + const psbt = dt.mockPsbtDefaultWithDescriptorTemplate("Wsh2Of3"); + const outputs = psbt.getOutputsWithAddress("btc"); + for (const output of outputs) { + // p2wsh addresses start with bc1 + assert.ok(output.address.startsWith("bc1"), `expected bc1 prefix, got ${output.address}`); + } + }); + + it("returns tbtc testnet addresses starting with expected prefixes", () => { + const psbt = dt.mockPsbtDefaultWithDescriptorTemplate("Wsh2Of3"); + const outputs = psbt.getOutputsWithAddress("tbtc"); + for (const output of outputs) { + // p2wsh testnet addresses start with tb1 + assert.ok(output.address.startsWith("tb1"), `expected tb1 prefix, got ${output.address}`); + } + }); + + it("works for all wsh/tr templates", () => { + for (const t of [ + "Wsh2Of3", + "Wsh2Of2", + "Wsh2Of3CltvDrop", + "Tr2Of3-NoKeyPath", + "Tr1Of3-NoKeyPath-Tree", + ] as dt.DescriptorTemplate[]) { + const psbt = dt.mockPsbtDefaultWithDescriptorTemplate(t, dt.getPsbtParams(t)); + const outputs = psbt.getOutputsWithAddress("btc"); + assert.strictEqual(outputs.length, 2, `${t}: expected 2 outputs`); + for (const output of outputs) { + assert.ok(output.address.length > 0, `${t}: address should not be empty`); + } + } + }); +}); + +describe("testutils.fixtures", () => { + describe("jsonNormalize", () => { + it("round-trips a simple object", () => { + const obj = { a: 1, b: "hello", c: [1, 2, 3] }; + assert.deepStrictEqual(testutils.jsonNormalize(obj), obj); + }); + + it("strips undefined values", () => { + const obj = { a: 1, b: undefined }; + assert.deepStrictEqual(testutils.jsonNormalize(obj), { a: 1 }); + }); + }); + + describe("toPlainObject", () => { + it("converts bigint to string", () => { + assert.strictEqual(testutils.toPlainObject(BigInt(42)), "42"); + }); + + it("converts Uint8Array to hex", () => { + assert.strictEqual( + testutils.toPlainObject(new Uint8Array([0xde, 0xad, 0xbe, 0xef])), + "deadbeef", + ); + }); + + it("converts nested objects", () => { + const obj = { a: BigInt(1), b: { c: new Uint8Array([0xff]) } }; + assert.deepStrictEqual(testutils.toPlainObject(obj), { + a: "1", + b: { c: "ff" }, + }); + }); + + it("converts functions to undefined", () => { + const obj = { a: 1, fn: () => {} }; + assert.deepStrictEqual(testutils.toPlainObject(obj), { a: 1, fn: undefined }); + }); + + it("supports ignorePaths", () => { + const obj = { a: 1, secret: "hidden", b: 2 }; + assert.deepStrictEqual(testutils.toPlainObject(obj, { ignorePaths: ["secret"] }), { + a: 1, + b: 2, + }); + }); + + it("supports apply transform", () => { + const obj = { a: 1, b: 2 }; + assert.deepStrictEqual( + testutils.toPlainObject(obj, { + apply: (v) => (typeof v === "number" ? v * 10 : undefined), + }), + { a: 10, b: 20 }, + ); + }); + }); +});