Skip to content

Commit 122eb28

Browse files
authored
Merge pull request #176 from BitGo/conventions
docs: add BitGoWasm API design conventions
2 parents 410655f + 4e5abab commit 122eb28

1 file changed

Lines changed: 368 additions & 0 deletions

File tree

CONVENTIONS.md

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
# BitGoWasm Code Conventions
2+
3+
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.
4+
5+
## These are hard rules, not suggestions. If you're unsure about a pattern, check the existing implementations in wasm-utxo.
6+
7+
## 1. Prefer Uint8Array, avoid unnecessary base conversions
8+
9+
**What:** Generally, binary formats like transactions should use `Uint8Array`. Avoid base conversions in the wasm interface.
10+
11+
**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.
12+
13+
### Exception: 0x-prefixed hex from external systems
14+
15+
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.
16+
17+
Two concrete examples:
18+
19+
#### `fromHex` on Substrate chains (DOT)
20+
21+
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.
22+
23+
#### Metadata stays as a hex string
24+
25+
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.
26+
27+
**Good:**
28+
29+
```typescript
30+
class Transaction {
31+
static fromBytes(bytes: Uint8Array): Transaction { ... }
32+
toBytes(): Uint8Array { ... }
33+
signablePayload(): Uint8Array { ... }
34+
}
35+
36+
// Encoding happens at the boundary
37+
const txBytes = Buffer.from(txHex, 'hex');
38+
const tx = Transaction.fromBytes(txBytes);
39+
```
40+
41+
**Bad:**
42+
43+
```typescript
44+
// ❌ Don't accept/return hex strings on Transaction (except fromHex for Substrate)
45+
class Transaction {
46+
toHex(): string { ... }
47+
}
48+
49+
// ❌ Don't mix encodings
50+
static fromBytes(bytes: Uint8Array | string): Transaction { ... }
51+
```
52+
53+
**See:** `packages/wasm-solana/js/transaction.ts`, `packages/wasm-utxo/js/transaction.ts`, `packages/wasm-dot/js/transaction.ts` (fromHex exception)
54+
55+
---
56+
57+
## 2. bigint for amounts, never string
58+
59+
**What:** All monetary amounts, lamports, satoshis, token quantities, fees — use `bigint`. Never `number` or `string`.
60+
61+
**Why:**
62+
63+
- `number` loses precision above 2^53 (unsafe for large amounts)
64+
- `string` delays type errors to runtime (no compile-time safety)
65+
- `bigint` is exact, type-safe, and enforces correctness at compile time
66+
67+
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.
68+
69+
**Good:**
70+
71+
```typescript
72+
export interface ExplainedOutput {
73+
address: string;
74+
amount: bigint; //
75+
}
76+
77+
const fee = 5000n;
78+
const total = amount + fee; // Type-safe bigint arithmetic
79+
```
80+
81+
**Bad:**
82+
83+
```typescript
84+
export interface ExplainedOutput {
85+
address: string;
86+
amount: string; // ❌ Runtime errors, no type safety
87+
}
88+
89+
const fee = "5000"; // ❌ Can't do arithmetic
90+
const total = parseInt(amount) + parseInt(fee); // ❌ Loses precision
91+
```
92+
93+
**See:** `packages/wasm-solana/js/explain.ts` (lines 40-43), `CLAUDE.md`
94+
95+
---
96+
97+
## 3. Const arrays for union types, not magic strings
98+
99+
**What:** Use `as const` arrays to define finite sets of known values. Never use bare string literals for types, opcodes, instruction names, etc.
100+
101+
**Why:**
102+
103+
- Compile-time checking (typos caught at build time)
104+
- IDE autocomplete
105+
- Exhaustiveness checking in switch statements
106+
- Less repetitive than `enum` (no `Key = "Key"` duplication)
107+
108+
**Good:**
109+
110+
```typescript
111+
export const TransactionType = ["Send", "StakingActivate", "StakingDeactivate"] as const;
112+
export type TransactionType = (typeof TransactionType)[number];
113+
114+
function handleTx(type: TransactionType) {
115+
switch (type) {
116+
case "Send":
117+
// ...
118+
case "StakingActivate":
119+
// ...
120+
// TypeScript warns if you miss a case
121+
}
122+
}
123+
```
124+
125+
**Bad:**
126+
127+
```typescript
128+
// ❌ No type safety, typos not caught
129+
function handleTx(type: string) {
130+
if (type === "send") {
131+
// Oops, wrong case
132+
// ...
133+
}
134+
}
135+
136+
// ❌ Magic strings scattered everywhere
137+
const txType = "Send";
138+
```
139+
140+
**See:** `packages/wasm-solana/js/explain.ts` (lines 19-28)
141+
142+
---
143+
144+
## 4. Return Transaction objects, not bytes (builders)
145+
146+
**What:** Builder functions and transaction constructors return `Transaction` objects, not raw `Uint8Array`. The caller serializes when they need bytes.
147+
148+
**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.
149+
150+
**Good:**
151+
152+
```typescript
153+
export function buildFromIntent(params: BuildParams): Transaction {
154+
const wasm = BuilderNamespace.build_from_intent(...);
155+
return Transaction.fromWasm(wasm);
156+
}
157+
158+
// Caller has full control
159+
const tx = buildFromIntent(intent);
160+
console.log(tx.feePayer); // Inspect
161+
tx.addSignature(pubkey, sig); // Modify
162+
const bytes = tx.toBytes(); // Serialize when ready
163+
```
164+
165+
**Bad:**
166+
167+
```typescript
168+
// ❌ Forces caller to re-parse for inspection
169+
export function buildFromIntent(params: BuildParams): Uint8Array {
170+
const wasm = BuilderNamespace.build_from_intent(...);
171+
return wasm.to_bytes();
172+
}
173+
174+
const bytes = buildFromIntent(intent);
175+
const tx = Transaction.fromBytes(bytes); // Unnecessary round-trip
176+
```
177+
178+
**See:** `packages/wasm-solana/js/intentBuilder.ts`, `packages/wasm-solana/js/builder.ts`
179+
180+
---
181+
182+
## 5. Parsing separate from Transaction, context at deserialization time
183+
184+
**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.
185+
186+
**Why:**
187+
188+
- Separation of concerns: deserialization is a protocol-level concept, parsing is a BitGo-level concept
189+
- `parseTransaction` accepts a `Transaction` object (not raw bytes) to avoid double-deserialization — the caller typically already has a `Transaction` from `fromBytes()` for the signing flow
190+
191+
### Context/material must be passed at deserialization time
192+
193+
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()`.
194+
195+
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.
196+
197+
```typescript
198+
// ✅ Material passed at deserialization time
199+
const tx = DotTransaction.fromHex(hex, material);
200+
const parsed = parseTransaction(tx, { material });
201+
202+
// ❌ Material passed only at parse time — call_data boundaries are already wrong
203+
const tx = DotTransaction.fromHex(hex); // Wrong boundaries baked in
204+
const parsed = parseTransaction(tx, { material }); // Can't fix it
205+
```
206+
207+
**Good:**
208+
209+
```typescript
210+
// Typical flow: decode once, use for both parsing and signing
211+
const tx = Transaction.fromBytes(txBytes);
212+
const parsed = parseTransaction(tx);
213+
if (!validateParsed(parsed, buildParams)) {
214+
throw new Error();
215+
}
216+
tx.addSignature(pubkey, signature);
217+
const signedBytes = tx.toBytes();
218+
219+
// Parsed data is for inspection only
220+
for (const instr of parsed.instructionsData) {
221+
if (instr.type === "Transfer") {
222+
console.log(`${instr.amount} to ${instr.toAddress}`);
223+
}
224+
}
225+
```
226+
227+
**Bad:**
228+
229+
```typescript
230+
// ❌ Don't accept raw bytes — forces redundant deserialization
231+
const parsed = parseTransaction(txBytes);
232+
233+
// ❌ Transaction does not have a .parse() method
234+
const tx = Transaction.fromBytes(txBytes);
235+
const parsed = tx.parse(); // Doesn't exist
236+
237+
// ❌ Don't use parseTransaction result for signing
238+
const parsed = parseTransaction(tx);
239+
parsed.addSignature(pubkey, sig); // Wrong object type
240+
```
241+
242+
**See:** `packages/wasm-solana/js/parser.ts` (parseTransaction function), `packages/wasm-solana/js/transaction.ts` (Transaction.fromBytes), `packages/wasm-dot/js/parser.ts`
243+
244+
---
245+
246+
## 6. Use wrapper classes
247+
248+
**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.
249+
250+
**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.
251+
252+
**See:** [`packages/wasm-utxo/js/README.md`](https://github.com/BitGo/BitGoWASM/blob/master/packages/wasm-utxo/js/README.md#purpose) for the full rationale and examples of both patterns.
253+
254+
---
255+
256+
## 7. Follow wasm-utxo conventions (get wasm(), fromBytes, toBytes, toBroadcastFormat)
257+
258+
**What:** All wrapper classes follow the same API pattern:
259+
260+
- `static fromBytes(bytes: Uint8Array)` — deserialize
261+
- `toBytes(): Uint8Array` — serialize
262+
- `toBroadcastFormat(): string` — serialize to broadcast-ready format (0x-prefixed hex for Substrate, hex for UTXO, base64 for Solana)
263+
- `getId(): string` — transaction ID / hash
264+
- `get wasm(): WasmType` (internal) — access underlying WASM instance
265+
266+
`toBroadcastFormat()` is the standard name for "give me the string I submit to the network". The encoding varies by chain but the method name is consistent. Don't add `toHex()` as a separate method — if callers want hex they can do `Buffer.from(tx.toBytes()).toString('hex')`.
267+
268+
**Why:**
269+
270+
- Consistency across packages (wasm-utxo, wasm-solana, wasm-dot, wasm-mps all work the same way)
271+
- Predictable API for consumers
272+
- `get wasm()` allows package-internal code to access WASM without exposing it publicly
273+
274+
**Good:**
275+
276+
```typescript
277+
export class Transaction {
278+
private constructor(private _wasm: WasmTransaction) {}
279+
280+
static fromBytes(bytes: Uint8Array): Transaction {
281+
return new Transaction(WasmTransaction.from_bytes(bytes));
282+
}
283+
284+
toBytes(): Uint8Array {
285+
return this._wasm.to_bytes();
286+
}
287+
288+
toBroadcastFormat(): string {
289+
return this._wasm.to_hex(); // or to_base64(), etc.
290+
}
291+
292+
getId(): string {
293+
return this._wasm.id;
294+
}
295+
296+
/** @internal */
297+
get wasm(): WasmTransaction {
298+
return this._wasm;
299+
}
300+
}
301+
```
302+
303+
**Bad:**
304+
305+
```typescript
306+
// ❌ Inconsistent naming
307+
export class Transaction {
308+
static parse(bytes: Uint8Array): Transaction { ... } // Should be fromBytes
309+
serialize(): Uint8Array { ... } // Should be toBytes
310+
toHex(): string { ... } // Should be toBroadcastFormat
311+
getTransactionId(): string { ... } // Should be getId
312+
}
313+
```
314+
315+
**See:** `packages/wasm-utxo/js/transaction.ts`, `packages/wasm-solana/js/transaction.ts`, `packages/wasm-dot/js/transaction.ts`
316+
317+
---
318+
319+
## 8. Explain logic belongs in BitGoJS, not in the wasm package
320+
321+
**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.
322+
323+
**Why:**
324+
325+
- `parseTransaction()` is chain-level: it decodes what the bytes contain
326+
- `explainTransaction()` is BitGo-level: it interprets what the transaction means in the context of BitGo's wallet operations (transaction types, output extraction, fee handling)
327+
- Keeping explain in the wasm package creates a dependency on BitGoJS types (`TransactionType`, `TransactionExplanation`) inside a package that should be chain-generic
328+
- Changes to explain logic (adding a new transaction type, adjusting output extraction) should be a BitGoJS PR, not a wasm package publish cycle
329+
330+
The wasm package exports `parseTransaction(tx) → ParsedTransaction`. BitGoJS imports it and builds `explainTransaction` on top in `wasmParser.ts`.
331+
332+
**Good:**
333+
334+
```typescript
335+
// In @bitgo/wasm-dot (wasm package)
336+
export function parseTransaction(tx: DotTransaction, context?: ParseContext): ParsedTransaction;
337+
338+
// In sdk-coin-dot (BitGoJS) — wasmParser.ts
339+
import { DotTransaction, parseTransaction } from "@bitgo/wasm-dot";
340+
341+
function buildExplanation(params) {
342+
const tx = DotTransaction.fromHex(params.txHex, params.material);
343+
const parsed = parseTransaction(tx, { material: params.material });
344+
// derive transaction type, extract outputs, map to TransactionExplanation...
345+
}
346+
```
347+
348+
**Bad:**
349+
350+
```typescript
351+
// ❌ Don't put explain logic in the wasm package
352+
// In @bitgo/wasm-dot
353+
import { TransactionType } from "@bitgo/sdk-core"; // Wrong dependency direction
354+
355+
export function explainTransaction(hex, context): TransactionExplanation {
356+
// BitGo-specific business logic doesn't belong here
357+
}
358+
```
359+
360+
**See:** `packages/wasm-dot/js/parser.ts`, BitGoJS `modules/sdk-coin-dot/src/lib/wasmParser.ts`
361+
362+
---
363+
364+
## Summary
365+
366+
These 8 conventions define how BitGoWasm packages structure their APIs. They're architectural patterns enforced in code reviews — not general software practices or build requirements.
367+
368+
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.

0 commit comments

Comments
 (0)