From e5f9621b37828346e96c32a0ab88f9cc0e3ad3d2 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 12 Jan 2026 14:29:41 +0100 Subject: [PATCH] feat(wasm-utxo): add chain code utilities for fixed-script wallets Implements a ChainCode utility module for managing derivation path components across various script types. Provides functions to map chain codes to script types and scopes, validate chain codes, and convert between representations. Includes comprehensive test coverage. Issue: BTC-2650 Co-authored-by: llm-git --- .../wasm-utxo/js/fixedScriptWallet/chains.ts | 141 +++++++++++ .../wasm-utxo/js/fixedScriptWallet/index.ts | 1 + .../src/wasm/fixed_script_wallet/mod.rs | 31 ++- packages/wasm-utxo/test/fixedScript/chains.ts | 229 ++++++++++++++++++ 4 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/chains.ts create mode 100644 packages/wasm-utxo/test/fixedScript/chains.ts 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"); + } + }); + }); +});