This file documents API design and architecture patterns from code reviews. Following these conventions prevents review churn and keeps the codebase consistent across wasm-utxo, wasm-solana, wasm-mps, and future packages.
These are hard rules, not suggestions. If you're unsure about a pattern, check the existing implementations in wasm-utxo.
What: Generally, binary formats like transactions should use Uint8Array. Avoid base conversions in the wasm interface.
Why: Type safety at the boundary. If a method accepts or returns transaction bytes, it's always Uint8Array. String encodings (hex, base64) belong in serialization/API layers, not in the core transaction model. This prevents API bloat where we have to add encoding and decoding variants for various base conversion formats, as well as inefficiencies due to round-tripping binary data through two base conversions.
When a chain's canonical wire format is 0x-prefixed hex (Substrate state_getMetadata, Ethereum JSON-RPC, etc.), accept hex strings directly rather than forcing callers to convert to Uint8Array at every boundary. The hex-to-bytes decode should happen once, internally in the WASM layer. This avoids the Buffer.from('0x...', 'hex') footgun (silently returns empty buffer with 0x prefix) and removes pointless conversion friction when hex is how the data naturally flows through the system.
Two concrete examples:
Substrate tooling (txwrapper, polkadot.js) always produces 0x-prefixed hex. The fromHex() method strips the prefix in the Rust/WASM layer before hex-decoding, avoiding the JavaScript footgun entirely. Use fromHex() as the primary entry point for Substrate chain deserialization. Use fromBytes() only when you already have raw bytes.
Runtime metadata (Material.metadata) is returned as a 0x-prefixed hex string from the Substrate state_getMetadata RPC and is stored/transported as hex through JSON APIs (coinSpecific, material cache, etc.). Forcing callers to convert to Uint8Array at every boundary adds friction and no value — the hex-to-bytes decode happens once, internally in the WASM layer, right before SCALE decoding.
Good:
class Transaction {
static fromBytes(bytes: Uint8Array): Transaction { ... }
toBytes(): Uint8Array { ... }
signablePayload(): Uint8Array { ... }
}
// Encoding happens at the boundary
const txBytes = Buffer.from(txHex, 'hex');
const tx = Transaction.fromBytes(txBytes);Bad:
// ❌ Don't accept/return hex strings on Transaction (except fromHex for Substrate)
class Transaction {
toHex(): string { ... }
}
// ❌ Don't mix encodings
static fromBytes(bytes: Uint8Array | string): Transaction { ... }See: packages/wasm-solana/js/transaction.ts, packages/wasm-utxo/js/transaction.ts, packages/wasm-dot/js/transaction.ts (fromHex exception)
What: All monetary amounts, lamports, satoshis, token quantities, fees — use bigint. Never number or string.
Why:
numberloses precision above 2^53 (unsafe for large amounts)stringdelays type errors to runtime (no compile-time safety)bigintis exact, type-safe, and enforces correctness at compile time
Conversions between external representations (API strings, JSON numbers) and bigint are the caller's responsibility, outside the wasm-* package boundary. The wasm package API accepts and returns bigint only — no string or number overloads for amounts.
Good:
export interface ExplainedOutput {
address: string;
amount: bigint; // ✅
}
const fee = 5000n;
const total = amount + fee; // Type-safe bigint arithmeticBad:
export interface ExplainedOutput {
address: string;
amount: string; // ❌ Runtime errors, no type safety
}
const fee = "5000"; // ❌ Can't do arithmetic
const total = parseInt(amount) + parseInt(fee); // ❌ Loses precisionSee: packages/wasm-solana/js/explain.ts (lines 40-43), CLAUDE.md
What: Use as const arrays to define finite sets of known values. Never use bare string literals for types, opcodes, instruction names, etc.
Why:
- Compile-time checking (typos caught at build time)
- IDE autocomplete
- Exhaustiveness checking in switch statements
- Less repetitive than
enum(noKey = "Key"duplication) - TypeScript
enumvalues cause type cast issues when crossing WASM/serde boundaries. Theas constpattern avoids this entirely.
Good:
export const TransactionType = ["Send", "StakingActivate", "StakingDeactivate"] as const;
export type TransactionType = (typeof TransactionType)[number];
export const TonStakingType = ["TonWhales", "SingleNominator", "MultiNominator"] as const;
export type TonStakingType = (typeof TonStakingType)[number];
function handleTx(type: TransactionType) {
switch (type) {
case "Send":
// ...
case "StakingActivate":
// ...
// TypeScript warns if you miss a case
}
}Bad:
// ❌ TypeScript enum — causes type cast issues across WASM boundaries
enum TransactionType {
Send = "Send",
StakingActivate = "StakingActivate",
}
// ❌ No type safety, typos not caught
function handleTx(type: string) {
if (type === "send") {
// Oops, wrong case
}
}
// ❌ Magic strings scattered everywhere
const txType = "Send";See: packages/wasm-solana/js/explain.ts (lines 19-28)
What: Builder functions and transaction constructors return Transaction objects, not raw Uint8Array. The caller serializes when they need bytes.
Why: Transaction objects can be inspected and further modified (.addSignature(), .signWithKeypair()). Returning bytes forces the caller to re-parse if they need to inspect or modify.
Good:
export function buildFromIntent(params: BuildParams): Transaction {
const wasm = BuilderNamespace.build_from_intent(...);
return Transaction.fromWasm(wasm);
}
// Caller has full control
const tx = buildFromIntent(intent);
console.log(tx.feePayer); // Inspect
tx.addSignature(pubkey, sig); // Modify
const bytes = tx.toBytes(); // Serialize when readyBad:
// ❌ Forces caller to re-parse for inspection
export function buildFromIntent(params: BuildParams): Uint8Array {
const wasm = BuilderNamespace.build_from_intent(...);
return wasm.to_bytes();
}
const bytes = buildFromIntent(intent);
const tx = Transaction.fromBytes(bytes); // Unnecessary round-tripSee: packages/wasm-solana/js/intentBuilder.ts, packages/wasm-solana/js/builder.ts
What: Transaction deserialization (for signing) and transaction parsing (decoding instructions) are separate operations with separate entry points. Transaction.fromBytes() deserializes for signing. parseTransaction() is a standalone function that decodes a Transaction into structured data.
Why:
- Separation of concerns: deserialization is a protocol-level concept, parsing is a BitGo-level concept
parseTransactionaccepts aTransactionobject (not raw bytes) to avoid double-deserialization — the caller typically already has aTransactionfromfromBytes()for the signing flow
For chains where the byte layout depends on runtime configuration (e.g. Substrate signed extensions), the deserializer needs chain material/metadata to correctly identify field boundaries in the extrinsic bytes. This context must be passed to fromHex()/fromBytes(), not to parseTransaction().
If you deserialize without material and the chain has non-standard extensions (e.g. Westend's AuthorizeCall, StorageWeightReclaim), the call_data boundary lands in the wrong place. At that point the damage is done — tx.callData returns wrong bytes. parseTransaction() only uses context for name resolution (pallet index → name) and address formatting, not for re-parsing the byte layout.
// ✅ Material passed at deserialization time
const tx = DotTransaction.fromHex(hex, material);
const parsed = parseTransaction(tx, { material });
// ❌ Material passed only at parse time — call_data boundaries are already wrong
const tx = DotTransaction.fromHex(hex); // Wrong boundaries baked in
const parsed = parseTransaction(tx, { material }); // Can't fix itGood:
// Typical flow: decode once, use for both parsing and signing
const tx = Transaction.fromBytes(txBytes);
const parsed = parseTransaction(tx);
if (!validateParsed(parsed, buildParams)) {
throw new Error();
}
tx.addSignature(pubkey, signature);
const signedBytes = tx.toBytes();
// Parsed data is for inspection only
for (const instr of parsed.instructionsData) {
if (instr.type === "Transfer") {
console.log(`${instr.amount} to ${instr.toAddress}`);
}
}Bad:
// ❌ Don't accept raw bytes — forces redundant deserialization
const parsed = parseTransaction(txBytes);
// ❌ Transaction does not have a .parse() method
const tx = Transaction.fromBytes(txBytes);
const parsed = tx.parse(); // Doesn't exist
// ❌ Don't use parseTransaction result for signing
const parsed = parseTransaction(tx);
parsed.addSignature(pubkey, sig); // Wrong object typeSee: packages/wasm-solana/js/parser.ts (parseTransaction function), packages/wasm-solana/js/transaction.ts (Transaction.fromBytes), packages/wasm-dot/js/parser.ts
What: Wrap WASM-generated types in TypeScript classes that provide better type signatures, camelCase naming, and encapsulation. Don't expose raw WASM bindings to consumers.
Why: wasm-bindgen emits loose types (any, string | null) and snake_case naming. Wrapper classes provide precise TypeScript types, idiomatic JS naming, and hide WASM implementation details. Two patterns exist: namespace wrappers for stateless utilities, class wrappers for stateful objects.
See: packages/wasm-utxo/js/README.md for the full rationale and examples of both patterns.
What: All wrapper classes follow the same API pattern:
static fromBytes(bytes: Uint8Array)— deserializetoBytes(): Uint8Array— serializetoBroadcastFormat(): Uint8Array— serialize to broadcast-ready bytes. Default return type isUint8Array(same astoBytes()for most chains). Callers encode to whatever string format the RPC needs:Buffer.from(tx.toBroadcastFormat()).toString('base64')for TON,.toString('hex')for UTXO, etc.getId(): string— transaction ID / hashget wasm(): WasmType(internal) — access underlying WASM instance
toBroadcastFormat() returns Uint8Array by default, consistent with Convention #1 (prefer Uint8Array). Don't add toHex(), toBase64(), or other encoding methods. Callers handle string encoding at the boundary.
Exception: Chains where the 0x-prefix footgun applies (Substrate/DOT) may return a hex string to avoid the Buffer.from('0x...', 'hex') silent truncation issue. This is the same exception as fromHex() — only justified for 0x-prefixed ecosystems.
Why:
- Consistency across packages (wasm-utxo, wasm-solana, wasm-dot, wasm-mps all work the same way)
- Predictable API for consumers
get wasm()allows package-internal code to access WASM without exposing it publicly
Good:
export class Transaction {
private constructor(private _wasm: WasmTransaction) {}
static fromBytes(bytes: Uint8Array): Transaction {
return new Transaction(WasmTransaction.from_bytes(bytes));
}
toBytes(): Uint8Array {
return this._wasm.to_bytes();
}
toBroadcastFormat(): Uint8Array {
return this.toBytes(); // default: same as toBytes()
}
getId(): string {
return this._wasm.id;
}
/** @internal */
get wasm(): WasmTransaction {
return this._wasm;
}
}Bad:
// ❌ Inconsistent naming
export class Transaction {
static parse(bytes: Uint8Array): Transaction { ... } // Should be fromBytes
serialize(): Uint8Array { ... } // Should be toBytes
toHex(): string { ... } // Should be toBroadcastFormat
getTransactionId(): string { ... } // Should be getId
}See: packages/wasm-utxo/js/transaction.ts, packages/wasm-solana/js/transaction.ts, packages/wasm-dot/js/transaction.ts
What: The wasm package provides parseTransaction() which returns raw decoded data (pallet, method, args, nonce, tip, era). The explain logic — deriving transaction types, extracting outputs/inputs, mapping to BitGoJS TransactionExplanation format — belongs in the sdk-coin-* module in BitGoJS.
Why:
parseTransaction()is chain-level: it decodes what the bytes containexplainTransaction()is BitGo-level: it interprets what the transaction means in the context of BitGo's wallet operations (transaction types, output extraction, fee handling)- Keeping explain in the wasm package creates a dependency on BitGoJS types (
TransactionType,TransactionExplanation) inside a package that should be chain-generic - Changes to explain logic (adding a new transaction type, adjusting output extraction) should be a BitGoJS PR, not a wasm package publish cycle
The wasm package exports parseTransaction(tx) → ParsedTransaction. BitGoJS imports it and builds explainTransaction on top in wasmParser.ts.
Good:
// In @bitgo/wasm-dot (wasm package)
export function parseTransaction(tx: DotTransaction, context?: ParseContext): ParsedTransaction;
// In sdk-coin-dot (BitGoJS) — wasmParser.ts
import { DotTransaction, parseTransaction } from "@bitgo/wasm-dot";
function buildExplanation(params) {
const tx = DotTransaction.fromHex(params.txHex, params.material);
const parsed = parseTransaction(tx, { material: params.material });
// derive transaction type, extract outputs, map to TransactionExplanation...
}Bad:
// ❌ Don't put explain logic in the wasm package
// In @bitgo/wasm-dot
import { TransactionType } from "@bitgo/sdk-core"; // Wrong dependency direction
export function explainTransaction(hex, context): TransactionExplanation {
// BitGo-specific business logic doesn't belong here
}See: packages/wasm-dot/js/parser.ts, BitGoJS modules/sdk-coin-dot/src/lib/wasmParser.ts
What: When passing structured data (intents, build context) from TypeScript to Rust/WASM, pass the JS object directly and use serde_wasm_bindgen::from_value() in Rust. Do not JSON.stringify in TypeScript and serde_json::from_str() in Rust.
Why: JSON stringification is an unnecessary round-trip through string serialization. It adds a serializeIntent() function in TypeScript that shouldn't exist, bypasses serde_wasm_bindgen's type checking, and doesn't match the sol/dot pattern.
Good:
// TypeScript — pass object directly
const tx = BuilderNamespace.buildTransaction(intent, context);// Rust — deserialize from JS value
pub fn build_transaction(intent: JsValue, context: JsValue) -> Result<WasmTransaction, WasmError> {
let intent: TransactionIntent = serde_wasm_bindgen::from_value(intent)?;
let ctx: BuildContext = serde_wasm_bindgen::from_value(context)?;
// ...
}Bad:
// ❌ Don't serialize to JSON string in TypeScript
function serializeIntent(intent: TonIntent): string {
return JSON.stringify(intent, (_, v) => (typeof v === "bigint" ? v.toString() : v));
}
const tx = BuilderNamespace.buildTransaction(serializeIntent(intent), context);// ❌ Don't deserialize from JSON string in Rust
pub fn build_transaction(intent_json: &str, context: JsValue) -> Result<WasmTransaction, WasmError> {
let intent: TransactionIntent = serde_json::from_str(intent_json)?;
// ...
}Handling BigInt: If intent fields contain bigint values that serde_wasm_bindgen can't deserialize directly, use custom serde deserializers in Rust that accept both number and string:
// Rust — custom deserializer handles both u64 and "123" string
fn deserialize_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
struct U64Visitor;
impl<'de> Visitor<'de> for U64Visitor {
type Value = u64;
fn visit_u64<E>(self, v: u64) -> Result<u64, E> { Ok(v) }
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
v.parse().map_err(E::custom)
}
}
d.deserialize_any(U64Visitor)
}This keeps the TypeScript side clean (no serialization helpers) while handling the BigInt edge case in Rust.
See: packages/wasm-solana/src/intent/types.rs, packages/wasm-dot/src/builder/types.rs
These 9 conventions define how BitGoWasm packages structure their APIs. They're architectural patterns enforced in code reviews — not general software practices or build requirements.
When in doubt, look at wasm-solana and wasm-utxo — they're the reference implementations. Following these patterns from the start prevents review churn and keeps all packages consistent.