Files
suaveplan/openspec/archive/2026-05-17-add-cryptocurrency/design.md
2026-05-27 23:29:53 +01:00

11 KiB

Design — @suaveplan/cryptocurrency

Non-normative. Architecture, layout, and trade-off notes. Normative requirements live in specs/cryptocurrency/spec.md; the implementation contract lives in proposal.md § Parity Checklist.

1. Layout

packages/finance/cryptocurrency/
  src/
    address/
      validator.ts            # validateAddress, validateAddressForChain, validateTxHash, internal EIP-55 checksum
      validator.md
      validator.test.ts
      index.ts
    chains/
      chain-data.ts           # CHAINS: Record<ChainId, ChainInfo> with 15 entries
      chain-data.md
      chain-data.test.ts
      chain-registry.ts       # MemoryChainRegistry + ChainRegistry async façade + LoggerLike injection
      chain-registry.md
      chain-registry.test.ts
      registry.ts             # synchronous getChain / getAllChainIds / getEvmChains / getChainByEvmId / getExplorerAddressUrl / getExplorerTxUrl
      registry.md
      registry.test.ts
      index.ts
    error-codes/
      error-codes.ts          # CRYPTOCURRENCY_CODES + CryptocurrencyError extends SuaveplanError
      error-codes.md
      error-codes.test.ts
      index.ts
    gas/
      gas-estimator.ts        # estimateGas, calculateMaxFeePerGas, calculateEffectiveGasPrice, formatGwei, estimateTxCostEth
      gas-estimator.md
      gas-estimator.test.ts
      index.ts
    tokens/
      token-data.ts           # COMMON_TOKENS: TokenInfo[]
      token-data.md
      token-data.test.ts
      token-registry.ts       # TokenRegistry + formatTokenBalance
      token-registry.md
      token-registry.test.ts
      index.ts
    transaction/
      tx-decoder.ts           # KNOWN_SELECTORS + lookupSelector / extractSelector / decodeCalldata / decodeTxHash
      tx-decoder.md
      tx-decoder.test.ts
      index.ts
    types/
      crypto.types.ts         # ChainId, ChainInfo, ChainRegistryAdapter, CryptoUnit, AddressValidation, TxHashValidation, TokenInfo, GasEstimate, GasEstimateParams, TxMetadata, FunctionSignature
      crypto.types.md
    units/
      converter.ts            # convertUnits + ETH/BTC/SOL wrappers + getUnits
      converter.md
      converter.test.ts
      index.ts
    index.ts                  # explicit named root barrel
  tests/setup/polyfills.ts    # empty; universal-tier needs no polyfills
  README.md
  package.json
  vite.config.ts
  vitest.config.ts
  tsconfig.json
  tsconfig.lib.json

Naming conforms to rules §6a (no export * in feature barrels), §10 (no parent/child name redundancy — folder is address/, file is validator.ts, not address-validator.ts), and §11 (no inline object shapes in interfaces). Files stay well under the 300-line hard cap.

2. Public contract surface

The root barrel re-exports every public symbol explicitly (rule §6a). The full surface matches the upstream src/index.ts plus the new error registry:

// validation
export { validateAddress, validateAddressForChain, validateTxHash } from "./address/validator.js";

// chain data
export { CHAINS } from "./chains/chain-data.js";

// chain registry
export { ChainRegistry, MemoryChainRegistry } from "./chains/chain-registry.js";

// chain getters
export {
  getAllChainIds,
  getChain,
  getChainByEvmId,
  getEvmChains,
  getExplorerAddressUrl,
  getExplorerTxUrl,
} from "./chains/registry.js";

// gas
export {
  calculateEffectiveGasPrice,
  calculateMaxFeePerGas,
  estimateGas,
  estimateTxCostEth,
  formatGwei,
} from "./gas/gas-estimator.js";

// tokens
export { COMMON_TOKENS } from "./tokens/token-data.js";
export { formatTokenBalance, TokenRegistry } from "./tokens/token-registry.js";

// transaction
export {
  decodeCalldata,
  decodeTxHash,
  extractSelector,
  KNOWN_SELECTORS,
  lookupSelector,
} from "./transaction/tx-decoder.js";

// units
export {
  bitcoinToSatoshi,
  convertUnits,
  etherToWei,
  getUnits,
  gweiToEther,
  lamportToSol,
  satoshiToBitcoin,
  weiToEther,
} from "./units/converter.js";

// types
export type {
  AddressValidation,
  ChainId,
  ChainInfo,
  ChainRegistryAdapter,
  CryptoUnit,
  FunctionSignature,
  GasEstimate,
  GasEstimateParams,
  TokenInfo,
  TxHashValidation,
  TxMetadata,
} from "./types/crypto.types.js";

// errors
export {
  CRYPTOCURRENCY_CODES,
  CryptocurrencyError,
  type CryptocurrencyErrorCode,
  type CryptocurrencyErrorOptions,
} from "./error-codes/error-codes.js";

3. Error registry

Plain throw new Error("Unknown chain: …") boundaries in upstream become typed CryptocurrencyError raises carrying a stable CRYPTOCURRENCY.* code. The registry has exactly five codes — one per distinct failure mode in the migrated surface:

Code Raised by Discriminant fields
CRYPTOCURRENCY.UNKNOWN_CHAIN ChainRegistry.getExplorerAddressUrl / getExplorerTxUrl when the adapter returns no chain for the given id chainId
CRYPTOCURRENCY.UNKNOWN_CURRENCY convertUnits / getUnits when the currency tag is not ETH / BTC / SOL currency
CRYPTOCURRENCY.UNKNOWN_UNIT convertUnits when fromUnit or toUnit is not registered for the currency currency, unit
CRYPTOCURRENCY.INVALID_ADDRESS Reserved for callers — validateAddress returns a structured AddressValidation rather than throwing, but the code is exposed so callers can wrap the validation result with a typed error if they prefer chainId
CRYPTOCURRENCY.INVALID_HASH Reserved for callers — same rationale as INVALID_ADDRESS, for validateTxHash consumers chainId

Two codes (INVALID_ADDRESS, INVALID_HASH) are exposed but not thrown by the package's own code paths — the validators are pure-data returns by design ({ valid: false, error: "..." }). The codes exist so application-tier consumers that want to map validation failures to a typed throw can do so without inventing their own code. They are exercised in error-codes.test.ts by direct CryptocurrencyError construction; the conformance gate is satisfied because the registry's job is to enumerate the namespace consumers can dispatch on, not to force every code through a throw site inside this package. The error-codes.test.ts also pins the discriminant shape so the public types stay stable.

4. Type design

All public types stay in types/crypto.types.ts, preserving upstream's structure. The discriminated ChainInfo.addressType union ("base58" | "bech32" | "hex" | "bech32m" | "ss58") is preserved verbatim. Optional evmChainId distinguishes EVM from non-EVM chains. No inline object shapes inside any interface body (rule §11) — the upstream layout already complies.

ChainRegistryAdapter is an async interface (Promise-returning) so consumers can plug in DB-backed or HTTP-backed implementations later without changing the contract:

export interface ChainRegistryAdapter {
  getChain(id: ChainId): Promise<ChainInfo | undefined>;
  getAllChains(): Promise<ChainInfo[]>;
  getChainByEvmId(evmChainId: number): Promise<ChainInfo | undefined>;
  registerChain(chain: ChainInfo): Promise<void>;
}

The synchronous registry.ts helpers (getChain, getExplorerAddressUrl, …) are the fast-path for the static CHAINS constant. The async ChainRegistry is the extension point for callers wanting custom data sources.

5. EIP-55 algorithm — preserved-as-is

Upstream's "checksum" is a positional even → upper, odd → lower toy algorithm, not the keccak256-based EIP-55 algorithm. The audit (L57-58) flags this as a gap. We preserve upstream behaviour bit-for-bit in this migration so any consumer relying on the current output keeps working; swapping to keccak256 requires @suaveplan/crypto-hash and is a behaviour change. That swap belongs in a follow-up change (fix-cryptocurrency-eip55) gated on the hash dep landing.

The validator.md doc and the validateAddress JSDoc explicitly call out the simplified algorithm so callers are not misled.

6. LoggerLike injection

ChainRegistry accepts an optional LoggerLike in its constructor. Default is a no-op logger (four empty methods). When wired, the registry logs:

  • chain.registered — info — { chainId, evmChainId }
  • chain.lookup.miss — debug — { chainId } (only when getExplorerAddressUrl / getExplorerTxUrl would raise)

No other module needs logging — the pure helpers in registry.ts, gas-estimator.ts, converter.ts, and tx-decoder.ts have no operational events worth surfacing. This keeps the package leaf-like.

7. Tier purity

Universal tier — no node:*, no bun:*, no fs/path/stream/net/http/crypto bare specifiers. The package uses only standard JavaScript built-ins (BigInt, Map, RegExp, Object.keys, Array.from, String.prototype.padStart). performance.now() is not used. The package runs unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers.

package.json declares "sideEffects": false, no "browser" field, no engines.* pin.

8. Test runner imports

All test files import runner primitives from @suaveplan/testing/runner (rule §11). Upstream imported from @suaveplan/testing root — this is rewritten in the migration commit, not deferred.

9. File budget

Every source file fits comfortably under 300 lines:

File Upstream LOC Migrated LOC (est.)
address/validator.ts 187 ~195
chains/chain-data.ts 148 148
chains/chain-registry.ts 97 ~115
chains/registry.ts 37 ~38
error-codes/error-codes.ts — ~70
gas/gas-estimator.ts 99 99
tokens/token-data.ts 61 61
tokens/token-registry.ts 98 98
transaction/tx-decoder.ts 147 147
types/crypto.types.ts 148 148
units/converter.ts 169 ~175

Test files all stay under the 500-line cap.

10. Trade-offs & decisions

  • Preserve naive EIP-55 algorithm vs. swap to keccak256. Preserve. The migration's job is to make the upstream surface available on genesis with the error model swapped; behavioural deltas land in follow-up changes. (Audit L57-58 documents the long-term direction.)
  • Add @suaveplan/error + @suaveplan/types as runtime deps vs. hand-roll a local error class. Add the deps. The audit's "zero runtime deps" preference (L12) was satisfied upstream by hand-rolled Errors; genesis policy (CLAUDE.md rule §63) mandates the central SuaveplanError shape, and both packages are zero-runtime themselves (@suaveplan/types) or near-zero (@suaveplan/error). The duplication cost of forking the error shape locally outweighs the dep cost.
  • LoggerLike on ChainRegistry only vs. on every module. Only the async façade. The pure helpers have no operational events worth surfacing; adding logging there would be inert ceremony.
  • Five error codes vs. one per validator failure mode. Five. INVALID_ADDRESS / INVALID_HASH are exposed (without internal throws) so application-tier consumers wanting typed dispatch can wrap the validators' structured returns. The validators themselves keep their pure-data return contract for compatibility.
  • Subpath exports: 7 (upstream) vs. 11 (migrated). 11. Tree-shaking is the goal; exposing chain-data, token-data, types, error-codes rounds out the legitimate consumption surfaces upstream omitted.