Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 304 additions & 0 deletions CONVENTIONS.md
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.
Comment on lines +8 to +12
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## 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.
## 1. Prefer Uint8Array, avoid unnecessary base conversions
**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.
Exceptions are allowed for instances where a hex encoded representations are conventional (Ethereum addresses for instance)


**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).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to state that this conversion is always outside the wasm-* package? or maybe have an adapter/ namespace where we add these conversions?


**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
Copy link
Contributor

@OttoAllmendinger OttoAllmendinger Feb 24, 2026

Choose a reason for hiding this comment

The 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:

export const TransactionType = ["Send", "StakingActivate", "StakingDeactivate"] as const;
export type TransactionType = typeof TransactionType[number];


function handleTx(type: TransactionType) {
switch (type) {
case TransactionType.Send:
// ...
case TransactionType.StakingActivate:
// ...
// TypeScript warns if you miss a case
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- 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
- Separation of concerns: decoding is a protocol-level concept, parsing is a BitGo-level concept

- Matches wasm-utxo pattern (BitGoPsbt doesn't parse on construction)
Copy link
Contributor

Choose a reason for hiding this comment

The 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually would prefer if parseTransaction took the tx as an argument here

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also we probably need to figure out what the distinction between parse and explain is

maybe just remove the explain example here

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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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.