-
Notifications
You must be signed in to change notification settings - Fork 2
docs: add BitGoWasm API design conventions #176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,304 @@ | ||||||||||
| # BitGoWasm Code Conventions | ||||||||||
|
|
||||||||||
| 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. | ||||||||||
| --- | ||||||||||
|
|
||||||||||
| ## 1. Uint8Array everywhere, no hex/base64 on Transaction | ||||||||||
|
|
||||||||||
| **What:** Transaction objects work exclusively with `Uint8Array` for binary data. No hex strings, no base64 strings. | ||||||||||
|
|
||||||||||
| **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 silent bugs where someone passes hex to a function expecting raw bytes. | ||||||||||
|
|
||||||||||
| **Good:** | ||||||||||
| ```typescript | ||||||||||
| 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:** | ||||||||||
| ```typescript | ||||||||||
| // ❌ Don't accept/return hex strings on Transaction | ||||||||||
| class Transaction { | ||||||||||
| static fromHex(hex: string): 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` | ||||||||||
|
|
||||||||||
| --- | ||||||||||
|
|
||||||||||
| ## 2. bigint for amounts, never string | ||||||||||
|
|
||||||||||
| **What:** All monetary amounts, lamports, satoshis, token quantities, fees — use `bigint`. Never `number` or `string`. | ||||||||||
|
|
||||||||||
| **Why:** | ||||||||||
| - `number` loses precision above 2^53 (unsafe for large amounts) | ||||||||||
| - `string` delays type errors to runtime (no compile-time safety) | ||||||||||
| - `bigint` is exact, type-safe, and enforces correctness at compile time | ||||||||||
|
|
||||||||||
| Use `BigInt()` constructor when converting from external inputs. Use `String()` only at serialization boundaries (JSON responses, API output). | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we want to state that this conversion is always outside the |
||||||||||
|
|
||||||||||
| **Good:** | ||||||||||
| ```typescript | ||||||||||
| export interface ExplainedOutput { | ||||||||||
| address: string; | ||||||||||
| amount: bigint; // ✅ | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const fee = 5000n; | ||||||||||
| const total = amount + fee; // Type-safe bigint arithmetic | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **Bad:** | ||||||||||
| ```typescript | ||||||||||
| 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 precision | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **See:** `packages/wasm-solana/js/explain.ts` (lines 40-43), `CLAUDE.md` | ||||||||||
|
|
||||||||||
| --- | ||||||||||
|
|
||||||||||
| ## 3. Enums, not magic strings | ||||||||||
|
|
||||||||||
| **What:** Use TypeScript `enum` (string enums) for 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 | ||||||||||
| - Self-documenting code | ||||||||||
|
|
||||||||||
| **Good:** | ||||||||||
| ```typescript | ||||||||||
| export enum TransactionType { | ||||||||||
| Send = "Send", | ||||||||||
| StakingActivate = "StakingActivate", | ||||||||||
| StakingDeactivate = "StakingDeactivate", | ||||||||||
| } | ||||||||||
|
Comment on lines
+92
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually this is one of my least favorite ways to declare an enum. This is less repetitive and more robust: |
||||||||||
|
|
||||||||||
| function handleTx(type: TransactionType) { | ||||||||||
| switch (type) { | ||||||||||
| case TransactionType.Send: | ||||||||||
| // ... | ||||||||||
| case TransactionType.StakingActivate: | ||||||||||
| // ... | ||||||||||
| // TypeScript warns if you miss a case | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. even in the way I'm suggesting! |
||||||||||
| } | ||||||||||
| } | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **Bad:** | ||||||||||
| ```typescript | ||||||||||
| // ❌ 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) | ||||||||||
|
|
||||||||||
| --- | ||||||||||
|
|
||||||||||
| ## 4. Return Transaction objects, not bytes (builders) | ||||||||||
|
|
||||||||||
| **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 (`.instructions`, `.feePayer`, `.recentBlockhash`) | ||||||||||
| - Transaction objects can be further modified (`.addSignature()`, `.signWithKeypair()`) | ||||||||||
| - Returning bytes forces the caller to re-parse if they need to inspect | ||||||||||
|
Comment on lines
+128
to
+133
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. overly verbose |
||||||||||
| - Consistent with wasm-utxo pattern (builders return `BitGoPsbt`, not bytes) | ||||||||||
|
|
||||||||||
| **Good:** | ||||||||||
| ```typescript | ||||||||||
| 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 ready | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **Bad:** | ||||||||||
| ```typescript | ||||||||||
| // ❌ 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-trip | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **See:** `packages/wasm-solana/js/intentBuilder.ts`, `packages/wasm-solana/js/builder.ts` | ||||||||||
|
|
||||||||||
| --- | ||||||||||
|
|
||||||||||
| ## 5. Parsing separate from Transaction | ||||||||||
|
|
||||||||||
| **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 instructions into structured data. | ||||||||||
|
|
||||||||||
| **Why:** | ||||||||||
| - Not all use cases need full parsing (e.g., just signing) | ||||||||||
| - Parsing can be expensive (especially for complex instruction decoding) | ||||||||||
| - Separation of concerns — Transaction is for signing/serialization, parseTransaction is for decoding | ||||||||||
|
Comment on lines
+171
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| - Matches wasm-utxo pattern (BitGoPsbt doesn't parse on construction) | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's remove this from the convention, it should stand on it's own |
||||||||||
|
|
||||||||||
| **Good:** | ||||||||||
| ```typescript | ||||||||||
| // For signing — deserialize only | ||||||||||
| const tx = Transaction.fromBytes(txBytes); | ||||||||||
| tx.addSignature(pubkey, signature); | ||||||||||
| const signedBytes = tx.toBytes(); | ||||||||||
|
|
||||||||||
| // For parsing — decode instructions | ||||||||||
| const parsed = parseTransaction(txBytes, context); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. actually would prefer if |
||||||||||
| for (const instr of parsed.instructionsData) { | ||||||||||
| if (instr.type === 'Transfer') { | ||||||||||
| console.log(`${instr.amount} to ${instr.toAddress}`); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // For high-level explanation — business logic | ||||||||||
| const explained = explainTransaction(txBytes, options); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also we probably need to figure out what the distinction between maybe just remove the |
||||||||||
| console.log(explained.type); // "Send", "StakingActivate", etc. | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **Bad:** | ||||||||||
| ```typescript | ||||||||||
| // ❌ Transaction no longer has .parse() method | ||||||||||
| const tx = Transaction.fromBytes(txBytes); | ||||||||||
| const parsed = tx.parse(); // Doesn't exist | ||||||||||
|
|
||||||||||
| // ❌ Don't use parseTransaction result for signing | ||||||||||
| const parsed = parseTransaction(txBytes); | ||||||||||
| parsed.addSignature(pubkey, sig); // Wrong object type | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **See:** `packages/wasm-solana/js/parser.ts` (parseTransaction function), `packages/wasm-solana/js/transaction.ts` (Transaction.fromBytes), `packages/wasm-dot/js/parser.ts` | ||||||||||
|
|
||||||||||
| --- | ||||||||||
|
|
||||||||||
| ## 6. Follow wasm-utxo conventions (get wasm(), fromBytes, toBytes, getId) | ||||||||||
|
|
||||||||||
| **What:** All wrapper classes follow the same API pattern: | ||||||||||
| - `static fromBytes(bytes: Uint8Array)` — deserialize | ||||||||||
| - `toBytes(): Uint8Array` — serialize | ||||||||||
| - `getId(): string` — transaction ID / hash | ||||||||||
| - `get wasm(): WasmType` (internal) — access underlying WASM instance | ||||||||||
|
Comment on lines
+211
to
+217
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe add a separate rule further up "Use wrapper classes" The reasoning for the wrapper classes is explained here: https://github.com/BitGo/BitGoWASM/blob/master/packages/wasm-utxo/js/README.md#purpose |
||||||||||
|
|
||||||||||
| **Why:** | ||||||||||
| - Consistency across packages (wasm-utxo, wasm-solana, 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:** | ||||||||||
| ```typescript | ||||||||||
| 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(); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| getId(): string { | ||||||||||
| return this._wasm.id; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** @internal */ | ||||||||||
| get wasm(): WasmTransaction { | ||||||||||
| return this._wasm; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **Bad:** | ||||||||||
| ```typescript | ||||||||||
| // ❌ Inconsistent naming | ||||||||||
| export class Transaction { | ||||||||||
| static parse(bytes: Uint8Array): Transaction { ... } // Should be fromBytes | ||||||||||
| serialize(): Uint8Array { ... } // Should be toBytes | ||||||||||
| getTransactionId(): string { ... } // Should be getId | ||||||||||
| } | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **See:** `packages/wasm-utxo/js/transaction.ts`, `packages/wasm-solana/js/transaction.ts` | ||||||||||
|
|
||||||||||
| --- | ||||||||||
|
|
||||||||||
| ## 7. Use typed wrappers (e.g., Keypair) not string-encoded keys | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is somewhat redundant with our previous rules where we recommend strong types and explain how to build wrapper classes |
||||||||||
|
|
||||||||||
| **What:** Wrap WASM types in TypeScript classes (Keypair, Pubkey, Transaction). Don't pass around hex/base58 strings where a typed object should be used. | ||||||||||
|
|
||||||||||
| **Why:** | ||||||||||
| - Type safety (can't mix up a pubkey string with a signature string) | ||||||||||
| - Encapsulation (implementation details hidden) | ||||||||||
| - Consistent API (methods, not free functions on strings) | ||||||||||
| - Prevents passing the wrong encoding (hex vs base58 vs raw bytes) | ||||||||||
|
|
||||||||||
| **Good:** | ||||||||||
| ```typescript | ||||||||||
| export class Keypair { | ||||||||||
| private constructor(private _wasm: WasmKeypair) {} | ||||||||||
|
|
||||||||||
| static fromSecretKey(secretKey: Uint8Array): Keypair { ... } | ||||||||||
| getAddress(): string { ... } | ||||||||||
| sign(message: Uint8Array): Uint8Array { ... } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Usage | ||||||||||
| const keypair = Keypair.fromSecretKey(secretKeyBytes); | ||||||||||
| tx.signWithKeypair(keypair); // Type-safe | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **Bad:** | ||||||||||
| ```typescript | ||||||||||
| // ❌ Stringly-typed API | ||||||||||
| function signTransaction(tx: Transaction, secretKeyHex: string) { | ||||||||||
| // Is this hex? base58? 32 bytes or 64 bytes? | ||||||||||
| // Caller has to know implementation details | ||||||||||
| } | ||||||||||
| ``` | ||||||||||
|
|
||||||||||
| **See:** `packages/wasm-solana/js/keypair.ts`, `packages/wasm-solana/js/pubkey.ts` | ||||||||||
|
|
||||||||||
| --- | ||||||||||
|
|
||||||||||
| ## Summary | ||||||||||
|
|
||||||||||
| These 7 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. | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.