Skip to content

Commit 1d9072d

Browse files
Merge pull request #91 from BitGo/BTC-2650.add-wasm-utxo-chains
feat(wasm-utxo): add chain code utilities for fixed-script wallets
2 parents ea3caff + e5f9621 commit 1d9072d

4 files changed

Lines changed: 401 additions & 1 deletion

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Chain code utilities for BitGo fixed-script wallets.
3+
*
4+
* Chain codes define the derivation path component for different script types
5+
* and scopes (external/internal) in the format `m/0/0/{chain}/{index}`.
6+
*/
7+
import { FixedScriptWalletNamespace } from "../wasm/wasm_utxo.js";
8+
import type { OutputScriptType } from "./scriptType.js";
9+
10+
/** All valid chain codes as a const tuple */
11+
export const chainCodes = [0, 1, 10, 11, 20, 21, 30, 31, 40, 41] as const;
12+
13+
/** A valid chain code value */
14+
export type ChainCode = (typeof chainCodes)[number];
15+
16+
/** Whether a chain is for receiving (external) or change (internal) addresses */
17+
export type Scope = "internal" | "external";
18+
19+
// Build static lookup tables once at module load time
20+
const chainCodeSet = new Set<number>(chainCodes);
21+
const chainToMeta = new Map<ChainCode, { scope: Scope; scriptType: OutputScriptType }>();
22+
const scriptTypeToChain = new Map<OutputScriptType, { internal: ChainCode; external: ChainCode }>();
23+
24+
// Initialize from WASM (called once at load time)
25+
function assertChainCode(n: number): ChainCode {
26+
if (!chainCodeSet.has(n)) {
27+
throw new Error(`Invalid chain code from WASM: ${n}`);
28+
}
29+
return n as ChainCode;
30+
}
31+
32+
function assertScope(s: string): Scope {
33+
if (s !== "internal" && s !== "external") {
34+
throw new Error(`Invalid scope from WASM: ${s}`);
35+
}
36+
return s;
37+
}
38+
39+
for (const tuple of FixedScriptWalletNamespace.chain_code_table() as unknown[]) {
40+
if (!Array.isArray(tuple) || tuple.length !== 3) {
41+
throw new Error(`Invalid chain_code_table entry: expected [number, string, string]`);
42+
}
43+
const [rawCode, rawScriptType, rawScope] = tuple as [unknown, unknown, unknown];
44+
45+
if (typeof rawCode !== "number") {
46+
throw new Error(`Invalid chain code type: ${typeof rawCode}`);
47+
}
48+
if (typeof rawScriptType !== "string") {
49+
throw new Error(`Invalid scriptType type: ${typeof rawScriptType}`);
50+
}
51+
if (typeof rawScope !== "string") {
52+
throw new Error(`Invalid scope type: ${typeof rawScope}`);
53+
}
54+
55+
const code = assertChainCode(rawCode);
56+
const scriptType = rawScriptType as OutputScriptType;
57+
const scope = assertScope(rawScope);
58+
59+
chainToMeta.set(code, { scope, scriptType });
60+
61+
let entry = scriptTypeToChain.get(scriptType);
62+
if (!entry) {
63+
entry = {} as { internal: ChainCode; external: ChainCode };
64+
scriptTypeToChain.set(scriptType, entry);
65+
}
66+
entry[scope] = code;
67+
}
68+
69+
/**
70+
* ChainCode namespace with utility functions for working with chain codes.
71+
*/
72+
export const ChainCode = {
73+
/**
74+
* Check if a value is a valid chain code.
75+
*
76+
* @example
77+
* ```typescript
78+
* if (ChainCode.is(maybeChain)) {
79+
* // maybeChain is now typed as ChainCode
80+
* const scope = ChainCode.scope(maybeChain);
81+
* }
82+
* ```
83+
*/
84+
is(n: unknown): n is ChainCode {
85+
return typeof n === "number" && chainCodeSet.has(n);
86+
},
87+
88+
/**
89+
* Get the chain code for a script type and scope.
90+
*
91+
* @example
92+
* ```typescript
93+
* const externalP2wsh = ChainCode.value("p2wsh", "external"); // 20
94+
* const internalP2tr = ChainCode.value("p2trLegacy", "internal"); // 31
95+
* ```
96+
*/
97+
value(scriptType: OutputScriptType | "p2tr", scope: Scope): ChainCode {
98+
// legacy alias for p2trLegacy
99+
if (scriptType === "p2tr") {
100+
scriptType = "p2trLegacy";
101+
}
102+
103+
const entry = scriptTypeToChain.get(scriptType);
104+
if (!entry) {
105+
throw new Error(`Invalid scriptType: ${scriptType}`);
106+
}
107+
return entry[scope];
108+
},
109+
110+
/**
111+
* Get the scope (external/internal) for a chain code.
112+
*
113+
* @example
114+
* ```typescript
115+
* ChainCode.scope(0); // "external"
116+
* ChainCode.scope(1); // "internal"
117+
* ChainCode.scope(20); // "external"
118+
* ```
119+
*/
120+
scope(chainCode: ChainCode): Scope {
121+
const meta = chainToMeta.get(chainCode);
122+
if (!meta) throw new Error(`Invalid chainCode: ${chainCode}`);
123+
return meta.scope;
124+
},
125+
126+
/**
127+
* Get the script type for a chain code.
128+
*
129+
* @example
130+
* ```typescript
131+
* ChainCode.scriptType(0); // "p2sh"
132+
* ChainCode.scriptType(20); // "p2wsh"
133+
* ChainCode.scriptType(40); // "p2trMusig2"
134+
* ```
135+
*/
136+
scriptType(chainCode: ChainCode): OutputScriptType {
137+
const meta = chainToMeta.get(chainCode);
138+
if (!meta) throw new Error(`Invalid chainCode: ${chainCode}`);
139+
return meta.scriptType;
140+
},
141+
};

packages/wasm-utxo/js/fixedScriptWallet/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.j
66
export { outputScript, address } from "./address.js";
77
export { Dimensions } from "./Dimensions.js";
88
export { type OutputScriptType, type InputScriptType, type ScriptType } from "./scriptType.js";
9+
export { ChainCode, chainCodes, type Scope } from "./chains.js";
910

1011
// Bitcoin-like PSBT (for all non-Zcash networks)
1112
export {

packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use wasm_bindgen::JsValue;
99

1010
use crate::address::networks::AddressFormat;
1111
use crate::error::WasmUtxoError;
12-
use crate::fixed_script_wallet::wallet_scripts::OutputScriptType;
12+
use crate::fixed_script_wallet::wallet_scripts::{OutputScriptType, Scope};
1313
use crate::fixed_script_wallet::{Chain, WalletScripts};
1414
use crate::utxolib_compat::UtxolibNetwork;
1515
use crate::wasm::bip32::WasmBIP32;
@@ -107,6 +107,35 @@ impl FixedScriptWalletNamespace {
107107
let st = OutputScriptType::from_str(script_type).map_err(|e| WasmUtxoError::new(&e))?;
108108
Ok(network.output_script_support().supports_script_type(st))
109109
}
110+
111+
/// Get all chain code metadata for building TypeScript lookup tables
112+
///
113+
/// Returns an array of [chainCode, scriptType, scope] tuples where:
114+
/// - chainCode: u32 (0, 1, 10, 11, 20, 21, 30, 31, 40, 41)
115+
/// - scriptType: string ("p2sh", "p2shP2wsh", "p2wsh", "p2trLegacy", "p2trMusig2")
116+
/// - scope: string ("external" or "internal")
117+
#[wasm_bindgen]
118+
pub fn chain_code_table() -> JsValue {
119+
use js_sys::Array;
120+
121+
let result = Array::new();
122+
123+
for script_type in OutputScriptType::all() {
124+
for scope in [Scope::External, Scope::Internal] {
125+
let chain = Chain::new(*script_type, scope);
126+
let tuple = Array::new();
127+
tuple.push(&JsValue::from(chain.value()));
128+
tuple.push(&JsValue::from_str(script_type.as_str()));
129+
tuple.push(&JsValue::from_str(match scope {
130+
Scope::External => "external",
131+
Scope::Internal => "internal",
132+
}));
133+
result.push(&tuple);
134+
}
135+
}
136+
137+
result.into()
138+
}
110139
}
111140
#[wasm_bindgen]
112141
pub struct BitGoPsbt {

0 commit comments

Comments
 (0)