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

234 lines
11 KiB
Markdown

# 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
```text
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:
```ts
// 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:
```ts
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 `Error`s; 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.