diff --git a/packages/wasm-utxo/js/fixedScriptWallet/chains.ts b/packages/wasm-utxo/js/fixedScriptWallet/chains.ts new file mode 100644 index 0000000..b9655df --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/chains.ts @@ -0,0 +1,141 @@ +/** + * Chain code utilities for BitGo fixed-script wallets. + * + * Chain codes define the derivation path component for different script types + * and scopes (external/internal) in the format `m/0/0/{chain}/{index}`. + */ +import { FixedScriptWalletNamespace } from "../wasm/wasm_utxo.js"; +import type { OutputScriptType } from "./scriptType.js"; + +/** All valid chain codes as a const tuple */ +export const chainCodes = [0, 1, 10, 11, 20, 21, 30, 31, 40, 41] as const; + +/** A valid chain code value */ +export type ChainCode = (typeof chainCodes)[number]; + +/** Whether a chain is for receiving (external) or change (internal) addresses */ +export type Scope = "internal" | "external"; + +// Build static lookup tables once at module load time +const chainCodeSet = new Set(chainCodes); +const chainToMeta = new Map(); +const scriptTypeToChain = new Map(); + +// Initialize from WASM (called once at load time) +function assertChainCode(n: number): ChainCode { + if (!chainCodeSet.has(n)) { + throw new Error(`Invalid chain code from WASM: ${n}`); + } + return n as ChainCode; +} + +function assertScope(s: string): Scope { + if (s !== "internal" && s !== "external") { + throw new Error(`Invalid scope from WASM: ${s}`); + } + return s; +} + +for (const tuple of FixedScriptWalletNamespace.chain_code_table() as unknown[]) { + if (!Array.isArray(tuple) || tuple.length !== 3) { + throw new Error(`Invalid chain_code_table entry: expected [number, string, string]`); + } + const [rawCode, rawScriptType, rawScope] = tuple as [unknown, unknown, unknown]; + + if (typeof rawCode !== "number") { + throw new Error(`Invalid chain code type: ${typeof rawCode}`); + } + if (typeof rawScriptType !== "string") { + throw new Error(`Invalid scriptType type: ${typeof rawScriptType}`); + } + if (typeof rawScope !== "string") { + throw new Error(`Invalid scope type: ${typeof rawScope}`); + } + + const code = assertChainCode(rawCode); + const scriptType = rawScriptType as OutputScriptType; + const scope = assertScope(rawScope); + + chainToMeta.set(code, { scope, scriptType }); + + let entry = scriptTypeToChain.get(scriptType); + if (!entry) { + entry = {} as { internal: ChainCode; external: ChainCode }; + scriptTypeToChain.set(scriptType, entry); + } + entry[scope] = code; +} + +/** + * ChainCode namespace with utility functions for working with chain codes. + */ +export const ChainCode = { + /** + * Check if a value is a valid chain code. + * + * @example + * ```typescript + * if (ChainCode.is(maybeChain)) { + * // maybeChain is now typed as ChainCode + * const scope = ChainCode.scope(maybeChain); + * } + * ``` + */ + is(n: unknown): n is ChainCode { + return typeof n === "number" && chainCodeSet.has(n); + }, + + /** + * Get the chain code for a script type and scope. + * + * @example + * ```typescript + * const externalP2wsh = ChainCode.value("p2wsh", "external"); // 20 + * const internalP2tr = ChainCode.value("p2trLegacy", "internal"); // 31 + * ``` + */ + value(scriptType: OutputScriptType | "p2tr", scope: Scope): ChainCode { + // legacy alias for p2trLegacy + if (scriptType === "p2tr") { + scriptType = "p2trLegacy"; + } + + const entry = scriptTypeToChain.get(scriptType); + if (!entry) { + throw new Error(`Invalid scriptType: ${scriptType}`); + } + return entry[scope]; + }, + + /** + * Get the scope (external/internal) for a chain code. + * + * @example + * ```typescript + * ChainCode.scope(0); // "external" + * ChainCode.scope(1); // "internal" + * ChainCode.scope(20); // "external" + * ``` + */ + scope(chainCode: ChainCode): Scope { + const meta = chainToMeta.get(chainCode); + if (!meta) throw new Error(`Invalid chainCode: ${chainCode}`); + return meta.scope; + }, + + /** + * Get the script type for a chain code. + * + * @example + * ```typescript + * ChainCode.scriptType(0); // "p2sh" + * ChainCode.scriptType(20); // "p2wsh" + * ChainCode.scriptType(40); // "p2trMusig2" + * ``` + */ + scriptType(chainCode: ChainCode): OutputScriptType { + const meta = chainToMeta.get(chainCode); + if (!meta) throw new Error(`Invalid chainCode: ${chainCode}`); + return meta.scriptType; + }, +}; diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 6628d97..7afcb17 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -6,6 +6,7 @@ export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.j export { outputScript, address } from "./address.js"; export { Dimensions } from "./Dimensions.js"; export { type OutputScriptType, type InputScriptType, type ScriptType } from "./scriptType.js"; +export { ChainCode, chainCodes, type Scope } from "./chains.js"; // Bitcoin-like PSBT (for all non-Zcash networks) export { diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index a93b911..dd1425d 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -9,7 +9,7 @@ use wasm_bindgen::JsValue; use crate::address::networks::AddressFormat; use crate::error::WasmUtxoError; -use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; +use crate::fixed_script_wallet::wallet_scripts::{OutputScriptType, Scope}; use crate::fixed_script_wallet::{Chain, WalletScripts}; use crate::utxolib_compat::UtxolibNetwork; use crate::wasm::bip32::WasmBIP32; @@ -107,6 +107,35 @@ impl FixedScriptWalletNamespace { let st = OutputScriptType::from_str(script_type).map_err(|e| WasmUtxoError::new(&e))?; Ok(network.output_script_support().supports_script_type(st)) } + + /// Get all chain code metadata for building TypeScript lookup tables + /// + /// Returns an array of [chainCode, scriptType, scope] tuples where: + /// - chainCode: u32 (0, 1, 10, 11, 20, 21, 30, 31, 40, 41) + /// - scriptType: string ("p2sh", "p2shP2wsh", "p2wsh", "p2trLegacy", "p2trMusig2") + /// - scope: string ("external" or "internal") + #[wasm_bindgen] + pub fn chain_code_table() -> JsValue { + use js_sys::Array; + + let result = Array::new(); + + for script_type in OutputScriptType::all() { + for scope in [Scope::External, Scope::Internal] { + let chain = Chain::new(*script_type, scope); + let tuple = Array::new(); + tuple.push(&JsValue::from(chain.value())); + tuple.push(&JsValue::from_str(script_type.as_str())); + tuple.push(&JsValue::from_str(match scope { + Scope::External => "external", + Scope::Internal => "internal", + })); + result.push(&tuple); + } + } + + result.into() + } } #[wasm_bindgen] pub struct BitGoPsbt { diff --git a/packages/wasm-utxo/test/fixedScript/chains.ts b/packages/wasm-utxo/test/fixedScript/chains.ts new file mode 100644 index 0000000..eb161d6 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/chains.ts @@ -0,0 +1,229 @@ +import assert from "node:assert"; + +import * as utxolib from "@bitgo/utxo-lib"; + +import { fixedScriptWallet } from "../../js/index.js"; + +const { ChainCode, chainCodes } = fixedScriptWallet; + +describe("ChainCode", function () { + describe("chainCodes array", function () { + it("matches utxo-lib chainCodes", function () { + assert.deepStrictEqual([...chainCodes], [...utxolib.bitgo.chainCodes]); + }); + + it("has expected values", function () { + assert.deepStrictEqual([...chainCodes], [0, 1, 10, 11, 20, 21, 30, 31, 40, 41]); + }); + }); + + describe("ChainCode.is()", function () { + it("returns true for valid chain codes", function () { + for (const code of chainCodes) { + assert.strictEqual(ChainCode.is(code), true, `Expected ${code} to be valid`); + } + }); + + it("returns false for invalid chain codes", function () { + const invalidValues = [-1, 2, 3, 5, 9, 12, 15, 19, 22, 25, 29, 32, 35, 39, 42, 50, 100]; + for (const value of invalidValues) { + assert.strictEqual(ChainCode.is(value), false, `Expected ${value} to be invalid`); + } + }); + + it("returns false for non-numbers", function () { + const nonNumbers = [null, undefined, "0", "20", {}, [], true, false]; + for (const value of nonNumbers) { + assert.strictEqual( + ChainCode.is(value), + false, + `Expected ${JSON.stringify(value)} to be invalid`, + ); + } + }); + }); + + describe("ChainCode.scope()", function () { + it("returns correct scope for all chain codes", function () { + const expectedScopes: Array<[number, "external" | "internal"]> = [ + [0, "external"], + [1, "internal"], + [10, "external"], + [11, "internal"], + [20, "external"], + [21, "internal"], + [30, "external"], + [31, "internal"], + [40, "external"], + [41, "internal"], + ]; + + for (const [code, expectedScope] of expectedScopes) { + if (!ChainCode.is(code)) throw new Error(`Invalid chain code: ${code}`); + assert.strictEqual( + ChainCode.scope(code), + expectedScope, + `Expected scope for ${code} to be ${expectedScope}`, + ); + } + }); + + it("matches utxo-lib isExternalChainCode/isInternalChainCode", function () { + for (const code of chainCodes) { + const wasmScope = ChainCode.scope(code); + const utxolibIsExternal = utxolib.bitgo.isExternalChainCode(code); + const utxolibIsInternal = utxolib.bitgo.isInternalChainCode(code); + + if (wasmScope === "external") { + assert.strictEqual( + utxolibIsExternal, + true, + `Chain ${code}: expected utxolib to report external`, + ); + assert.strictEqual( + utxolibIsInternal, + false, + `Chain ${code}: expected utxolib to not report internal`, + ); + } else { + assert.strictEqual( + utxolibIsExternal, + false, + `Chain ${code}: expected utxolib to not report external`, + ); + assert.strictEqual( + utxolibIsInternal, + true, + `Chain ${code}: expected utxolib to report internal`, + ); + } + } + }); + }); + + describe("ChainCode.scriptType()", function () { + it("returns correct script type for all chain codes", function () { + const expectedTypes: Array<[number, string]> = [ + [0, "p2sh"], + [1, "p2sh"], + [10, "p2shP2wsh"], + [11, "p2shP2wsh"], + [20, "p2wsh"], + [21, "p2wsh"], + [30, "p2trLegacy"], + [31, "p2trLegacy"], + [40, "p2trMusig2"], + [41, "p2trMusig2"], + ]; + + for (const [code, expectedType] of expectedTypes) { + if (!ChainCode.is(code)) throw new Error(`Invalid chain code: ${code}`); + assert.strictEqual( + ChainCode.scriptType(code), + expectedType, + `Expected scriptType for ${code} to be ${expectedType}`, + ); + } + }); + + it("matches utxo-lib scriptTypeForChain", function () { + for (const code of chainCodes) { + const wasmType = ChainCode.scriptType(code); + const utxolibType = utxolib.bitgo.scriptTypeForChain(code); + + // utxo-lib uses "p2tr" while wasm uses "p2trLegacy" + const normalizedUtxolibType = utxolibType === "p2tr" ? "p2trLegacy" : utxolibType; + + assert.strictEqual( + wasmType, + normalizedUtxolibType, + `Chain ${code}: expected scriptType ${normalizedUtxolibType}, got ${wasmType}`, + ); + } + }); + }); + + describe("ChainCode.value()", function () { + it("returns correct chain code for scriptType and scope combinations", function () { + const testCases: Array<[string, "external" | "internal", number]> = [ + ["p2sh", "external", 0], + ["p2sh", "internal", 1], + ["p2shP2wsh", "external", 10], + ["p2shP2wsh", "internal", 11], + ["p2wsh", "external", 20], + ["p2wsh", "internal", 21], + ["p2trLegacy", "external", 30], + ["p2trLegacy", "internal", 31], + ["p2trMusig2", "external", 40], + ["p2trMusig2", "internal", 41], + ]; + + for (const [scriptType, scope, expectedCode] of testCases) { + const result = ChainCode.value(scriptType as fixedScriptWallet.OutputScriptType, scope); + assert.strictEqual( + result, + expectedCode, + `Expected value(${scriptType}, ${scope}) to be ${expectedCode}, got ${result}`, + ); + } + }); + + it("throws for invalid script type", function () { + assert.throws( + () => ChainCode.value("invalid" as fixedScriptWallet.OutputScriptType, "external"), + /Invalid scriptType/, + ); + }); + }); + + describe("round-trip conversions", function () { + it("value() -> scope() and scriptType() round-trips correctly", function () { + const scriptTypes: fixedScriptWallet.OutputScriptType[] = [ + "p2sh", + "p2shP2wsh", + "p2wsh", + "p2trLegacy", + "p2trMusig2", + ]; + const scopes: fixedScriptWallet.Scope[] = ["external", "internal"]; + + for (const scriptType of scriptTypes) { + for (const scope of scopes) { + const code = ChainCode.value(scriptType, scope); + assert.strictEqual(ChainCode.scriptType(code), scriptType); + assert.strictEqual(ChainCode.scope(code), scope); + } + } + + // legacy alias for p2trLegacy + assert.strictEqual(ChainCode.value("p2tr", "external"), 30); + assert.strictEqual(ChainCode.value("p2tr", "internal"), 31); + }); + + it("scriptType() and scope() -> value() round-trips correctly", function () { + for (const code of chainCodes) { + const scriptType = ChainCode.scriptType(code); + const scope = ChainCode.scope(code); + const roundTripped = ChainCode.value(scriptType, scope); + assert.strictEqual(roundTripped, code); + } + }); + }); + + describe("type narrowing with is()", function () { + it("allows using narrowed value with other ChainCode methods", function () { + const maybeChain: unknown = 20; + + if (ChainCode.is(maybeChain)) { + // TypeScript should allow this without error + const scope = ChainCode.scope(maybeChain); + const scriptType = ChainCode.scriptType(maybeChain); + + assert.strictEqual(scope, "external"); + assert.strictEqual(scriptType, "p2wsh"); + } else { + assert.fail("Expected 20 to be a valid ChainCode"); + } + }); + }); +});