Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/chains.ts
Original file line number Diff line number Diff line change
@@ -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<number>(chainCodes);
const chainToMeta = new Map<ChainCode, { scope: Scope; scriptType: OutputScriptType }>();
const scriptTypeToChain = new Map<OutputScriptType, { internal: ChainCode; external: ChainCode }>();

// 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;
},
};
1 change: 1 addition & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 30 additions & 1 deletion packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading