Skip to content

Commit e5f9621

Browse files
OttoAllmendingerllm-git
andcommitted
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 <llm-git@ttll.de>
1 parent 8d4f41d commit e5f9621

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)