Initial Commit
This commit is contained in:
233
openspec/archive/2026-05-17-add-cryptocurrency/design.md
Normal file
233
openspec/archive/2026-05-17-add-cryptocurrency/design.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# 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.
|
||||
159
openspec/archive/2026-05-17-add-cryptocurrency/proposal.md
Normal file
159
openspec/archive/2026-05-17-add-cryptocurrency/proposal.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Add @suaveplan/cryptocurrency — wallet validation, unit conversion, chain registry, transaction parsing
|
||||
|
||||
**Package**: `@suaveplan/cryptocurrency`
|
||||
**Status**: PROPOSED
|
||||
**Type**: migration (upstream `../suaveplan/packages/finance/cryptocurrency`)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/cryptocurrency/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`STATUS.md` line 174 lists `@suaveplan/cryptocurrency` as Pending. The upstream package is a small, dependency-light universal-tier utility library (8 implementation files, ~720 LOC of `src/`, zero `@suaveplan/*` runtime deps) covering wallet address validation across 15 chains, EIP-55 checksumming, unit conversion across ETH/BTC/SOL denominations, a static + adapter-backed chain registry, EIP-1559 gas helpers, a token registry, and a transaction calldata/selector decoder. No genesis package covers this surface, and downstream finance / wallet / payment packages will want to depend on it.
|
||||
|
||||
The migration preserves the entire public surface bit-for-bit. The only behavioural deltas are (a) replacing plain `throw new Error(...)` boundaries with `SuaveplanError` + a stable `CRYPTOCURRENCY.*` code registry (per CLAUDE.md rule §63), (b) adding the `defaultMessageResolver`-friendly error contract, and (c) gaining `LoggerLike` injection on `ChainRegistry` (no-op default) for parity with sibling packages. The enterprise audit (`../suaveplan/enterprise/cryptocurrency.md` §3-§4) lists ambitious future additions (ABI-aware calldata decoder, EIP-4844 blob-gas, testnets, on-chain token metadata adapter, ENS resolver) — these are explicitly out of scope for this change and belong in follow-up proposals layered on top of the migrated baseline.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `validateAddress(address)` — multi-chain address validation with chain auto-detection, returns `AddressValidation` with `valid` / `normalized` (EIP-55 checksummed for EVM) / `chains` / `error`.
|
||||
- ADDED: `validateAddressForChain(address, chainId)` — chain-locked variant; narrows `chains` to `[chainId]` on success.
|
||||
- ADDED: `validateTxHash(hash)` — recognises EVM (0x + 64 hex), UTXO (64 hex), and Solana (base58 87-88 char) hash shapes.
|
||||
- ADDED: `CHAINS` — `Record<ChainId, ChainInfo>` with 15 entries (bitcoin, ethereum, solana, polygon, arbitrum, optimism, avalanche, bsc, base, tron, litecoin, dogecoin, cardano, polkadot, cosmos).
|
||||
- ADDED: `getChain(id)`, `getAllChainIds()`, `getEvmChains()`, `getChainByEvmId(evmChainId)`, `getExplorerAddressUrl(chainId, address)`, `getExplorerTxUrl(chainId, txHash)` — pure synchronous helpers over the built-in `CHAINS` constant.
|
||||
- ADDED: `MemoryChainRegistry` implementing the async `ChainRegistryAdapter` contract; preloads built-in chains or accepts custom seed list; supports `getChain`/`getAllChains`/`getChainByEvmId`/`registerChain`.
|
||||
- ADDED: `ChainRegistry` — async façade over an injected `ChainRegistryAdapter`, defaulting to `MemoryChainRegistry`; adds `getEvmChains()` / `getExplorerAddressUrl()` / `getExplorerTxUrl()` and raises `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })` on missing chains.
|
||||
- ADDED: `estimateGas(params)` — EIP-1559 gas estimator returning `GasEstimate` with `gasLimit` / `gasPrice` / `maxFeePerGas` / `maxPriorityFeePerGas` / `totalCost` (bigint throughout); honours `legacyGasPrice` override.
|
||||
- ADDED: `calculateMaxFeePerGas(baseFee, priorityFee)` — EIP-1559 formula `2 * base + priority`.
|
||||
- ADDED: `calculateEffectiveGasPrice(baseFee, maxFee, priorityFee)` — `min(maxFee, baseFee + priorityFee)`.
|
||||
- ADDED: `formatGwei(weiValue)` — bigint → human-readable gwei string with sign + trailing-zero trim.
|
||||
- ADDED: `estimateTxCostEth(gasLimit, gasPriceWei)` — bigint → ETH decimal string.
|
||||
- ADDED: `COMMON_TOKENS` — 8 well-known ERC-20s on Ethereum mainnet (USDT, USDC, DAI, WBTC, WETH, LINK, UNI, MATIC).
|
||||
- ADDED: `TokenRegistry` — `addToken`/`getToken`/`getTokensByChain`/`searchBySymbol`/`searchByName`/`getAllTokens`/`size`; address keys are case-insensitive.
|
||||
- ADDED: `formatTokenBalance(rawBalance, decimals)` — bigint balance formatter with sign + trailing-zero trim.
|
||||
- ADDED: `KNOWN_SELECTORS` — 8 well-known ERC-20 / ERC-721 function signatures (transfer, approve, transferFrom, balanceOf, allowance, totalSupply, safeTransferFrom, setApprovalForAll).
|
||||
- ADDED: `lookupSelector(selector)`, `extractSelector(calldata)`, `decodeCalldata(calldata)`, `decodeTxHash(hash)` — calldata / tx-hash parsing helpers returning `FunctionSignature | undefined` or `TxMetadata`.
|
||||
- ADDED: `convertUnits(value, fromUnit, toUnit, currency)` — generic ETH/BTC/SOL unit conversion (bigint arithmetic).
|
||||
- ADDED: `weiToEther` / `etherToWei` / `gweiToEther` / `satoshiToBitcoin` / `bitcoinToSatoshi` / `lamportToSol` — convenience wrappers around `convertUnits`.
|
||||
- ADDED: `getUnits(currency)` — returns `CryptoUnit[]` listing every denomination for ETH / BTC / SOL.
|
||||
- ADDED: Type exports — `ChainId`, `ChainInfo`, `ChainRegistryAdapter`, `CryptoUnit`, `AddressValidation`, `TxHashValidation`, `TokenInfo`, `GasEstimate`, `GasEstimateParams`, `TxMetadata`, `FunctionSignature`.
|
||||
- ADDED: `CRYPTOCURRENCY_CODES` registry via `defineCodes("CRYPTOCURRENCY", [...])` — 5 codes: `UNKNOWN_CHAIN`, `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT`, `INVALID_ADDRESS`, `INVALID_HASH`.
|
||||
- ADDED: `CryptocurrencyError extends SuaveplanError<CryptocurrencyErrorCode>` with optional `chainId` / `currency` / `unit` discriminants.
|
||||
- ADDED: Optional `LoggerLike` injection on `ChainRegistry` constructor — no-op default; logs `chain.registered` / `chain.lookup.miss` events when wired.
|
||||
- ADDED: Subpath exports `./address/validator`, `./units/converter`, `./chains/registry`, `./chains/chain-registry`, `./chains/chain-data`, `./tokens/token-registry`, `./tokens/token-data`, `./gas/gas-estimator`, `./transaction/tx-decoder`, `./types/crypto.types`, `./error-codes/error-codes` — tree-shakeable.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **ABI-aware `decodeCalldata` rewrite** (enterprise audit §3 / §4). The naive selector lookup is preserved bit-for-bit; full ABI decoding (dynamic types, tuples) belongs in a follow-up `add-cryptocurrency-abi` change because it materially expands the type surface and pulls in non-trivial codec code. Reason: orthogonal to the address/unit/chain core this migration ships.
|
||||
- **Online selector resolver adapter** (enterprise audit §3). Network-bound; lands with the ABI decoder follow-up so the resolver interface can be co-designed.
|
||||
- **EIP-4844 blob-gas helpers** (enterprise audit §3). Will be added in `add-cryptocurrency-blob-gas`; the gas estimator's `GasEstimate` shape is left forward-compatible.
|
||||
- **Testnet chain entries + zkRollups / Aptos / Sui / Sei / extended Cosmos zones** (enterprise audit §4). The 15-chain mainnet set is preserved; testnets land in `add-cryptocurrency-testnets`.
|
||||
- **On-chain `OnChainTokenRegistry` accepting an RPC client** (enterprise audit §4). Requires an RPC-client abstraction not yet defined in genesis; defer until that interface exists.
|
||||
- **ENS / SNS / UD resolver adapter interface** (enterprise audit §4). Net-new surface; ships in `add-cryptocurrency-names`.
|
||||
- **UTXO / PSBT helpers** (enterprise audit §4). Different problem domain; ships in `add-cryptocurrency-utxo`.
|
||||
- **Property-based tests via `fast-check`** for round-trip conversions. The unit converter is fully covered by the upstream characterisation suite; property tests are a future enhancement, not a parity gate.
|
||||
- **Benchmarks (`src/__benchmarks__/`)** for EIP-55 / bech32 hot paths. Useful but not blocking for a pure utility migration with no IO; defer to enterprise wave.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Upstream source: `../suaveplan/packages/finance/cryptocurrency/src/` (8 implementation files + 7 test files, ~720 LOC). Every public symbol in upstream `src/index.ts` migrates as-is; layout normalises per the no-prefix and feature-folder rules (no folder/file renames are required — upstream already uses feature-folder layout without redundant prefixes).
|
||||
|
||||
Enterprise audit reference: `../suaveplan/enterprise/cryptocurrency.md`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Pure utility, zero runtime deps, tree-shakeable, `sideEffects: false` | L12 + L49 | verbatim — zero-dep design is the audit's headline constraint |
|
||||
| Address validation across 15 chains | L12 + L18-19 | verbatim public API: `validateAddress`, `validateAddressForChain` |
|
||||
| Auto-detect chain from address format | L12 | verbatim |
|
||||
| EIP-55 checksumming for EVM addresses | L12 | verbatim (simplified positional algorithm preserved; full keccak256 deferred to enterprise wave per L57-58) |
|
||||
| Tx-hash validation across EVM / UTXO / Solana | L12 + L19 | verbatim: `validateTxHash` |
|
||||
| Chain registry with 15 entries, EVM ids, explorer templates, native symbols | L12 + L19-20 | verbatim: `CHAINS`, `ChainInfo`, `ChainRegistry`, `MemoryChainRegistry`, getters |
|
||||
| Async `ChainRegistryAdapter` interface | L12 + L25 | verbatim |
|
||||
| EIP-1559 gas estimation helpers | L12 + L20-21 | verbatim public API: `estimateGas`, `calculateMaxFeePerGas`, `calculateEffectiveGasPrice`, `estimateTxCostEth`, `formatGwei` |
|
||||
| Unit conversion ETH/BTC/SOL + generic `convertUnits` | L12 + L23-24 | verbatim |
|
||||
| `getUnits(currency)` registry of denominations | L23 | verbatim |
|
||||
| Token registry with `COMMON_TOKENS` + `formatTokenBalance` | L12 + L21-22 | verbatim |
|
||||
| Transaction calldata / function selector decoder + `KNOWN_SELECTORS` | L12 + L22-23 | verbatim — naive matcher preserved; ABI decoding scoped out |
|
||||
| All public type exports | L24-25 | verbatim |
|
||||
| Subpath exports per public module | L49 | verbatim — preserves upstream's 7 subpaths, adds `./chains/chain-data`, `./tokens/token-data`, `./types/crypto.types`, `./error-codes/error-codes` to round out tree-shaking |
|
||||
| 100% test coverage | L63 | upstream is ~97.73%; genesis policy is hard 100% (rule §7) |
|
||||
| 100% docs coverage; sibling `.md` per public symbol | L64 + L97 | verbatim |
|
||||
| `@suaveplan/error`-aligned error hierarchy | L59 + L88 | verbatim — replaces plain `throw new Error(...)` boundaries with `CryptocurrencyError` + `CRYPTOCURRENCY.*` codes |
|
||||
| Universal-tier purity (no node/bun deps) | L63 + L65 (`workspace:*` + catalog) + L12 ("zero runtime deps") | verbatim — except `tslib` (per genesis convention) and `@suaveplan/error` + `@suaveplan/types` (genesis error-shape requirement; documented exception below) |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| 100% coverage threshold | Genesis-wide policy (rule §7), not package-specific; upstream tolerates ~97.73% |
|
||||
| Optional `LoggerLike` on `ChainRegistry` | Genesis convention across migrated packages; no-op default keeps zero runtime cost when unwired |
|
||||
| `@suaveplan/error` + `@suaveplan/types` runtime deps | Required to align with the central error registry + injectable contracts. The audit's "zero runtime deps" preference (L12, L67-68) was satisfied upstream by hand-rolled plain `Error`s; genesis policy (CLAUDE.md rule §63) mandates the central error shape, so two small typed-only deps are accepted. Both packages are zero-runtime themselves (`@suaveplan/types`) or near-zero (`@suaveplan/error`). |
|
||||
| Subpath exports for `./chains/chain-data`, `./tokens/token-data`, `./types/crypto.types`, `./error-codes/error-codes` | Tree-shaking completeness; the upstream package omitted these but the data constants and types are valid consumption surfaces |
|
||||
|
||||
### Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `validateAddress`, `validateAddressForChain`, `validateTxHash` | ✅ | ✅ | Behaviour bit-for-bit preserved |
|
||||
| EIP-55 checksum (positional simplified) | ✅ | ✅ | Preserved; full keccak256 deferred to enterprise wave |
|
||||
| 15-chain `CHAINS` constant | ✅ | ✅ | Identical chain entries |
|
||||
| `getChain` / `getAllChainIds` / `getEvmChains` / `getChainByEvmId` / `getExplorerAddressUrl` / `getExplorerTxUrl` | ✅ | ✅ | Identical |
|
||||
| `MemoryChainRegistry` + `ChainRegistry` async adapter façade | ✅ | ⬆️ Enhanced | `throw new Error("Unknown chain: …")` → `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })`; constructor gains optional `LoggerLike` |
|
||||
| `estimateGas` / `calculateMaxFeePerGas` / `calculateEffectiveGasPrice` / `formatGwei` / `estimateTxCostEth` | ✅ | ✅ | Identical bigint math |
|
||||
| `COMMON_TOKENS` + `TokenRegistry` + `formatTokenBalance` | ✅ | ✅ | Identical |
|
||||
| `KNOWN_SELECTORS` + `lookupSelector` / `extractSelector` / `decodeCalldata` / `decodeTxHash` | ✅ | ✅ | Identical (ABI-aware decode scoped out) |
|
||||
| `convertUnits` + ETH/BTC/SOL convenience wrappers + `getUnits` | ✅ | ⬆️ Enhanced | `throw new Error("Unknown …")` → `CryptocurrencyError` with `UNKNOWN_CURRENCY` / `UNKNOWN_UNIT` codes |
|
||||
| Subpath exports per public module | ✅ | ⬆️ Enhanced | Upstream exposes 7 subpaths; genesis exposes 11 (adds `chain-data`, `token-data`, `types`, `error-codes`) |
|
||||
| Plain `throw new Error(...)` at boundaries | ✅ | ⬆️ Replaced | All public errors now `CryptocurrencyError extends SuaveplanError` with stable `CRYPTOCURRENCY.*` codes |
|
||||
| `LoggerLike` injection on `ChainRegistry` | 🚫 upstream | ⬆️ Added | Genesis convention; no-op default |
|
||||
| `tslib` runtime dep | ✅ | ✅ | Preserved per genesis convention |
|
||||
| `@suaveplan/testing` dev dep | ✅ (upstream uses `@suaveplan/testing` root) | ⬆️ Replaced | Uses `@suaveplan/testing/runner` per rule §7 / §11; direct `vitest` / `bun:test` imports banned |
|
||||
| ABI-aware calldata decoder | 🚫 upstream | 🚫 deferred | Enterprise audit §3 wants this; scoped to `add-cryptocurrency-abi` follow-up so the codec API can be co-designed with the resolver interface |
|
||||
| Online selector resolver | 🚫 upstream | 🚫 deferred | Network-bound; bundle with ABI decoder follow-up |
|
||||
| EIP-4844 blob-gas | 🚫 upstream | 🚫 deferred | Net-new helpers; `add-cryptocurrency-blob-gas` follow-up |
|
||||
| Testnet chains / zkRollups / extended Cosmos / non-EVM L1s | 🚫 upstream | 🚫 deferred | `add-cryptocurrency-testnets` follow-up |
|
||||
| OnChainTokenRegistry (RPC-backed) | 🚫 upstream | 🚫 deferred | Requires RPC-client abstraction not yet in genesis |
|
||||
| ENS / SNS / UD resolver interface | 🚫 upstream | 🚫 deferred | `add-cryptocurrency-names` follow-up |
|
||||
| UTXO / PSBT helpers | 🚫 upstream | 🚫 deferred | Different problem domain; `add-cryptocurrency-utxo` follow-up |
|
||||
| Benchmarks under `src/__benchmarks__/` | 🚫 upstream | 🚫 deferred | Defer to enterprise wave; pure-CPU helpers without IO are noise-bound |
|
||||
|
||||
### Reference docs consulted
|
||||
|
||||
- ✅ `../suaveplan/packages/finance/cryptocurrency/src/` — all 8 implementation files read end-to-end
|
||||
- ✅ `../suaveplan/packages/finance/cryptocurrency/src/**/*.test.ts` — all 7 test files read for characterisation
|
||||
- ✅ `../suaveplan/enterprise/cryptocurrency.md` — full enterprise audit
|
||||
- ✅ `../suaveplan/packages/finance/cryptocurrency/package.json` — public exports / dep graph
|
||||
- ✅ `../suaveplan/packages/finance/cryptocurrency/README.md` — public API examples
|
||||
- 🚫 `../suaveplan/openspec/specs/finance/spec.md` — no formal upstream spec exists for this capability; the parity contract is the source itself + the enterprise audit
|
||||
- 🚫 `../suaveplan/openspec/changes/*/proposal.md` touching cryptocurrency — none found
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta is pinned to one or more concrete test files that will exist after this change ships:
|
||||
|
||||
- `src/address/validator.test.ts` — covers address validation, chain auto-detection, EIP-55 checksumming, tx-hash validation
|
||||
- `src/chains/registry.test.ts` — covers synchronous chain getters + explorer URL helpers
|
||||
- `src/chains/chain-registry.test.ts` — covers `MemoryChainRegistry` + `ChainRegistry` async façade + `UNKNOWN_CHAIN` error path
|
||||
- `src/chains/chain-data.test.ts` — covers `CHAINS` constant shape (counts, EVM ids, explorer URLs)
|
||||
- `src/gas/gas-estimator.test.ts` — covers `estimateGas` / `calculateMaxFeePerGas` / `calculateEffectiveGasPrice` / `formatGwei` / `estimateTxCostEth`
|
||||
- `src/tokens/token-registry.test.ts` — covers `TokenRegistry` + `COMMON_TOKENS` + `formatTokenBalance`
|
||||
- `src/tokens/token-data.test.ts` — covers `COMMON_TOKENS` shape invariants
|
||||
- `src/transaction/tx-decoder.test.ts` — covers `KNOWN_SELECTORS` + `lookupSelector` / `extractSelector` / `decodeCalldata` / `decodeTxHash`
|
||||
- `src/units/converter.test.ts` — covers `convertUnits` + ETH/BTC/SOL wrappers + `getUnits` + `UNKNOWN_CURRENCY` / `UNKNOWN_UNIT` errors
|
||||
- `src/error-codes/error-codes.test.ts` — pins every `CRYPTOCURRENCY.*` code to a real call site + `CryptocurrencyError` `cause`-preservation contract
|
||||
|
||||
## Migration impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents today — `@suaveplan/cryptocurrency` is being added net-new on genesis. Future consumers (`payment`, `wallet`, `currency-providers`, `react-payment`) will land in subsequent migrations against the public contract this proposal locks in.
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/cryptocurrency` post-migration to confirm zero dependents at archive time.
|
||||
|
||||
### Risk
|
||||
|
||||
- **Low.** Pure utility library; no IO, no global state, no concurrency. Every helper is a pure function or a small class wrapping a `Map`. The only deviations from upstream are (a) error-type substitution (drop-in: same throw points, richer instance) and (b) optional `LoggerLike` injection (additive). Both are covered by new tests.
|
||||
- The simplified positional EIP-55 algorithm preserved from upstream is **not** RFC-correct (a real EIP-55 implementation requires keccak256). The audit (L57-58) flags this; the upstream behaviour is preserved so consumers depending on its checksum output keep working. A future change can swap to keccak256 once `@suaveplan/crypto-hash` is wired in.
|
||||
|
||||
### Migration notes
|
||||
|
||||
None. Net-new package on genesis with zero consumers; the public contract this proposal locks in is the baseline.
|
||||
@@ -0,0 +1,294 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: cryptocurrency (cryptocurrency)
|
||||
|
||||
**Package**: `@suaveplan/cryptocurrency`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL ship as a universal-tier package — no imports from `node:*`, `bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto` in any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. The `package.json` SHALL NOT declare a `"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL declare `"sideEffects": false`. The package SHALL NOT use `performance.now()`, `Date.now()`, or any other ambient global beyond plain ECMAScript built-ins (`BigInt`, `Map`, `RegExp`, `Object`, `Array`, `String`, `Number`).
|
||||
|
||||
#### Scenario: no platform imports in src/
|
||||
|
||||
- **WHEN** a CI step runs `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/cryptocurrency/src/`
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `src/units/converter.test.ts::"converter source has no node/bun imports (tier purity)"` AND `src/chains/chain-registry.test.ts::"chain-registry source has no node/bun imports (tier purity)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable CRYPTOCURRENCY_CODES error registry
|
||||
|
||||
The package SHALL declare an error-code registry via `defineCodes("CRYPTOCURRENCY", [...])` containing exactly the codes `UNKNOWN_CHAIN`, `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT`, `INVALID_ADDRESS`, `INVALID_HASH`. The package SHALL export a `CryptocurrencyError extends SuaveplanError<CryptocurrencyErrorCode>` carrying optional `chainId` / `currency` / `unit` discriminants. Every public method that previously threw a plain `Error` SHALL now throw a `CryptocurrencyError` with one of the registered codes. `INVALID_ADDRESS` and `INVALID_HASH` SHALL be exposed for application-tier consumers wishing to wrap the validators' structured returns with typed errors; they SHALL be reachable via direct `CryptocurrencyError` construction and exercised by the test suite.
|
||||
|
||||
#### Scenario: every CRYPTOCURRENCY.* code is exposed and constructible
|
||||
|
||||
- **WHEN** the test suite constructs `new CryptocurrencyError(code, msg)` for each of the five registered codes
|
||||
- **THEN** each instance is `instanceof CryptocurrencyError`, `instanceof SuaveplanError`, and `error.code` matches the expected `CRYPTOCURRENCY.*` string
|
||||
|
||||
#### Scenario: SuaveplanError contract preserved
|
||||
|
||||
- **WHEN** a `CryptocurrencyError` is thrown with a `cause` (e.g. wrapping an adapter exception)
|
||||
- **THEN** the resulting error exposes the original on `error.cause` and survives the `SuaveplanError` `toWire()` / `fromWire()` round-trip
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every CRYPTOCURRENCY.* code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"CryptocurrencyError preserves cause and SuaveplanError contract"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: validateAddress detects matching chains and EIP-55 checksums EVM input
|
||||
|
||||
The `validateAddress(address)` function SHALL return an `AddressValidation` object whose `valid` is true when the input matches at least one registered chain regex (EVM hex, Bitcoin legacy/segwit, Litecoin legacy/segwit, Solana base58, TRON base58, Dogecoin base58, Cardano bech32, Cosmos bech32, Polkadot ss58), with `chains` listing every matching `ChainId`. For EVM-matching inputs the function SHALL populate `normalized` with the simplified positional EIP-55 checksum of the address. The function SHALL trim leading/trailing whitespace before validation. The function SHALL return `{ valid: false, chains: [], error: "Address must be a non-empty string" }` for empty / null / non-string input, and `{ valid: false, chains: [], error: "Address does not match any known format" }` when no pattern matches.
|
||||
|
||||
#### Scenario: EVM address matches all seven EVM chains and yields a checksummed normalized form
|
||||
|
||||
- **WHEN** `validateAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28")` is called
|
||||
- **THEN** `result.valid === true`, `result.chains` contains `"ethereum"`, `"polygon"`, `"arbitrum"`, `"optimism"`, `"avalanche"`, `"bsc"`, `"base"`, and `result.normalized` is the EIP-55 checksummed string
|
||||
|
||||
#### Scenario: Bitcoin bech32 matches only bitcoin
|
||||
|
||||
- **WHEN** `validateAddress("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")` is called
|
||||
- **THEN** `result.valid === true` and `result.chains` equals `["bitcoin"]`
|
||||
|
||||
#### Scenario: malformed input is rejected with the documented error message
|
||||
|
||||
- **WHEN** `validateAddress("not-an-address")` is called
|
||||
- **THEN** `result.valid === false` and `result.error === "Address does not match any known format"`
|
||||
|
||||
**Verified by:** `src/address/validator.test.ts::"validates EVM address"` AND `src/address/validator.test.ts::"validates Bitcoin Segwit address (bc1)"` AND `src/address/validator.test.ts::"rejects invalid address"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: validateAddressForChain narrows result chains
|
||||
|
||||
The `validateAddressForChain(address, chainId)` function SHALL return the result of `validateAddress(address)` with `chains` narrowed to `[chainId]` when the address validates for that chain. When the address fails `validateAddress` the original failure SHALL be propagated. When the address validates but does not include `chainId` in its matched set, the function SHALL return `{ valid: false, chains: <original-matches>, error: \`Address is not valid for ${chainId}\` }`.
|
||||
|
||||
#### Scenario: EVM address validated for a specific EVM chain
|
||||
|
||||
- **WHEN** `validateAddressForChain("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28", "ethereum")` is called
|
||||
- **THEN** `result.valid === true` and `result.chains` equals `["ethereum"]`
|
||||
|
||||
#### Scenario: EVM address rejected for non-EVM chain
|
||||
|
||||
- **WHEN** `validateAddressForChain("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28", "bitcoin")` is called
|
||||
- **THEN** `result.valid === false` and `result.error` contains the substring `"not valid for bitcoin"`
|
||||
|
||||
**Verified by:** `src/address/validator.test.ts::"validates EVM address for ethereum"` AND `src/address/validator.test.ts::"rejects EVM address for bitcoin"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: validateTxHash recognises EVM, UTXO, and Solana hash shapes
|
||||
|
||||
The `validateTxHash(hash)` function SHALL recognise three hash forms: EVM (`0x` + 64 hex chars) maps to the seven EVM chains; UTXO (64 hex chars, no prefix) maps to `["bitcoin", "litecoin", "dogecoin"]`; Solana (base58 87-88 chars) maps to `["solana"]`. Input is trimmed before matching. Empty / null / non-string input SHALL return `{ valid: false, chains: [], error: "Hash must be a non-empty string" }`. Unrecognised input SHALL return `{ valid: false, chains: [], error: "Hash does not match any known format" }`.
|
||||
|
||||
#### Scenario: EVM hash maps to all seven EVM chains
|
||||
|
||||
- **WHEN** `validateTxHash("0x" + "a".repeat(64))` is called
|
||||
- **THEN** `result.valid === true` and `result.chains` includes `"ethereum"` and `"polygon"`
|
||||
|
||||
#### Scenario: UTXO hash maps to bitcoin / litecoin / dogecoin
|
||||
|
||||
- **WHEN** `validateTxHash("a".repeat(64))` is called
|
||||
- **THEN** `result.valid === true` and `result.chains` equals `["bitcoin", "litecoin", "dogecoin"]`
|
||||
|
||||
**Verified by:** `src/address/validator.test.ts::"validates EVM tx hash (0x prefix)"` AND `src/address/validator.test.ts::"validates Bitcoin tx hash (no prefix)"` AND `src/address/validator.test.ts::"validates Solana tx signature"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CHAINS constant exposes 15 mainnet entries
|
||||
|
||||
The package SHALL export a `CHAINS: Record<ChainId, ChainInfo>` constant containing exactly the 15 mainnet entries: `bitcoin`, `ethereum`, `solana`, `polygon`, `arbitrum`, `optimism`, `avalanche`, `bsc`, `base`, `tron`, `litecoin`, `dogecoin`, `cardano`, `polkadot`, `cosmos`. Each entry SHALL declare `id`, `name`, `nativeSymbol`, `addressType`, `explorerUrl`, `decimals`, `testnet: false`. EVM chains SHALL declare a numeric `evmChainId` matching the canonical mainnet id (Ethereum 1, BSC 56, Polygon 137, Optimism 10, Base 8453, Arbitrum 42161, Avalanche 43114). Non-EVM chains SHALL omit `evmChainId`.
|
||||
|
||||
#### Scenario: every ChainId is present and well-formed
|
||||
|
||||
- **WHEN** the test asserts the keys of `CHAINS` against the 15-chain set
|
||||
- **THEN** every chain is present and every entry's `id` field equals its key
|
||||
|
||||
#### Scenario: EVM chain ids match canonical mainnet values
|
||||
|
||||
- **WHEN** the test asserts `CHAINS.ethereum.evmChainId === 1`, `CHAINS.polygon.evmChainId === 137`, `CHAINS.base.evmChainId === 8453`
|
||||
- **THEN** every assertion passes
|
||||
|
||||
**Verified by:** `src/chains/chain-data.test.ts::"CHAINS contains all 15 mainnet entries"` AND `src/chains/chain-data.test.ts::"EVM chains carry the canonical mainnet evmChainId"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: synchronous chain getters operate on the built-in CHAINS constant
|
||||
|
||||
The package SHALL export pure synchronous helpers `getChain(id)`, `getAllChainIds()`, `getEvmChains()`, `getChainByEvmId(evmChainId)`, `getExplorerAddressUrl(chainId, address)`, `getExplorerTxUrl(chainId, txHash)`. `getChain` SHALL return the `ChainInfo` directly. `getAllChainIds` SHALL return all 15 ids. `getEvmChains` SHALL return only chains with a defined `evmChainId`. `getChainByEvmId` SHALL return the matching chain or `undefined`. The explorer URL helpers SHALL return `\`${chain.explorerUrl}/address/${address}\`` / `\`${chain.explorerUrl}/tx/${txHash}\``.
|
||||
|
||||
#### Scenario: getChainByEvmId returns ethereum for 1
|
||||
|
||||
- **WHEN** `getChainByEvmId(1)` is called
|
||||
- **THEN** the result is the `ChainInfo` with `id === "ethereum"`
|
||||
|
||||
#### Scenario: getChainByEvmId returns undefined for unknown id
|
||||
|
||||
- **WHEN** `getChainByEvmId(99999)` is called
|
||||
- **THEN** the result is `undefined`
|
||||
|
||||
#### Scenario: explorer URLs concatenate template + path segment
|
||||
|
||||
- **WHEN** `getExplorerAddressUrl("ethereum", "0xabc")` and `getExplorerTxUrl("bitcoin", "abc123")` are called
|
||||
- **THEN** the results are `"https://etherscan.io/address/0xabc"` and `"https://blockchair.com/bitcoin/tx/abc123"` respectively
|
||||
|
||||
**Verified by:** `src/chains/registry.test.ts::"finds chain by EVM chain ID"` AND `src/chains/registry.test.ts::"returns undefined for unknown chain ID"` AND `src/chains/registry.test.ts::"builds address URL"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ChainRegistry async façade with adapter injection and UNKNOWN_CHAIN error
|
||||
|
||||
The package SHALL export `MemoryChainRegistry implements ChainRegistryAdapter` and `ChainRegistry` classes. `MemoryChainRegistry` SHALL preload the built-in `CHAINS` set when constructed without arguments and SHALL accept a custom `ChainInfo[]` seed otherwise. It SHALL expose async `getChain` / `getAllChains` / `getChainByEvmId` / `registerChain` methods. `ChainRegistry` SHALL default to a fresh `MemoryChainRegistry` when no adapter is injected, SHALL forward calls to the adapter, SHALL provide async `getEvmChains` / `getExplorerAddressUrl` / `getExplorerTxUrl` helpers, and SHALL raise `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })` when the adapter returns no chain for an explorer URL request. The `ChainRegistry` constructor SHALL accept an optional `LoggerLike` (no-op default) and SHALL log `chain.registered` (info) and `chain.lookup.miss` (debug) events when one is provided.
|
||||
|
||||
#### Scenario: MemoryChainRegistry preloads built-in chains
|
||||
|
||||
- **WHEN** `new MemoryChainRegistry()` is constructed and `getAllChains()` is awaited
|
||||
- **THEN** the returned array has length 15
|
||||
|
||||
#### Scenario: MemoryChainRegistry accepts a custom seed
|
||||
|
||||
- **WHEN** `new MemoryChainRegistry([customChain])` is constructed and `getAllChains()` is awaited
|
||||
- **THEN** the returned array contains exactly `[customChain]`
|
||||
|
||||
#### Scenario: ChainRegistry raises UNKNOWN_CHAIN for missing explorer lookups
|
||||
|
||||
- **WHEN** a `ChainRegistry` is constructed over an empty adapter and `getExplorerAddressUrl("ethereum", "0xabc")` is awaited
|
||||
- **THEN** the promise rejects with a `CryptocurrencyError` whose `code === "CRYPTOCURRENCY.UNKNOWN_CHAIN"` and `chainId === "ethereum"`
|
||||
|
||||
#### Scenario: ChainRegistry logs registration and lookup-miss events when a logger is provided
|
||||
|
||||
- **WHEN** a `ChainRegistry` is constructed with a recording `LoggerLike`, `registerChain` is awaited, and an unknown-chain explorer lookup is attempted
|
||||
- **THEN** the logger records a `chain.registered` info event AND a `chain.lookup.miss` debug event
|
||||
|
||||
**Verified by:** `src/chains/chain-registry.test.ts::"MemoryChainRegistry loads default chains on construction"` AND `src/chains/chain-registry.test.ts::"MemoryChainRegistry loads custom initial chains"` AND `src/chains/chain-registry.test.ts::"ChainRegistry raises CRYPTOCURRENCY.UNKNOWN_CHAIN on unknown chain"` AND `src/chains/chain-registry.test.ts::"ChainRegistry logs chain.registered and chain.lookup.miss events"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EIP-1559 gas estimation helpers
|
||||
|
||||
The package SHALL export `estimateGas(params)`, `calculateMaxFeePerGas(baseFee, priorityFee)`, `calculateEffectiveGasPrice(baseFee, maxFee, priorityFee)`, `formatGwei(weiValue)`, `estimateTxCostEth(gasLimit, gasPriceWei)`. All numeric inputs and outputs SHALL be `bigint` (no `number` boundary). `estimateGas` SHALL compute `maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas`, SHALL honour an optional `legacyGasPrice` override on `gasPrice`, and SHALL compute `totalCost = gasLimit * effectiveGasPrice`. `calculateMaxFeePerGas` SHALL implement `2 * baseFee + priorityFee`. `calculateEffectiveGasPrice` SHALL implement `min(maxFee, baseFee + priorityFee)`. `formatGwei` SHALL produce a sign-preserving decimal gwei string with trailing zeros trimmed. `estimateTxCostEth` SHALL produce a decimal ETH string from `gasLimit * gasPriceWei` divided by `10^18`.
|
||||
|
||||
#### Scenario: estimateGas with EIP-1559 inputs
|
||||
|
||||
- **WHEN** `estimateGas({ gasLimit: 21000n, baseFeePerGas: 30000000000n, maxPriorityFeePerGas: 2000000000n })` is called
|
||||
- **THEN** the result has `maxFeePerGas === 32000000000n`, `gasPrice === 32000000000n`, and `totalCost === 21000n * 32000000000n`
|
||||
|
||||
#### Scenario: estimateGas honours legacyGasPrice
|
||||
|
||||
- **WHEN** `estimateGas` is called with `legacyGasPrice: 25000000000n`
|
||||
- **THEN** `result.gasPrice === 25000000000n` and `result.totalCost === gasLimit * 25000000000n`
|
||||
|
||||
#### Scenario: formatGwei trims trailing zeros and preserves sign
|
||||
|
||||
- **WHEN** `formatGwei(2500000000n)` and `formatGwei(-2500000000n)` are called
|
||||
- **THEN** the results are `"2.5"` and `"-2.5"` respectively
|
||||
|
||||
#### Scenario: estimateTxCostEth produces ETH decimal string
|
||||
|
||||
- **WHEN** `estimateTxCostEth(21000n, 30000000000n)` is called
|
||||
- **THEN** the result is `"0.00063"`
|
||||
|
||||
**Verified by:** `src/gas/gas-estimator.test.ts::"calculates gas estimate from EIP-1559 params"` AND `src/gas/gas-estimator.test.ts::"uses legacy gas price when provided"` AND `src/gas/gas-estimator.test.ts::"formats fractional gwei"` AND `src/gas/gas-estimator.test.ts::"handles negative values"` AND `src/gas/gas-estimator.test.ts::"estimates simple transfer cost"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TokenRegistry with case-insensitive address keys + COMMON_TOKENS
|
||||
|
||||
The package SHALL export `TokenRegistry` with `addToken` / `getToken` / `getTokensByChain` / `searchBySymbol` / `searchByName` / `getAllTokens` methods and a `size` accessor. `getToken(chainId, address)` SHALL be case-insensitive on the address. The constructor SHALL accept `preload = true` (default), preloading the registry with `COMMON_TOKENS`; passing `false` SHALL start empty. `searchBySymbol` / `searchByName` SHALL perform case-insensitive substring matches. The package SHALL export `COMMON_TOKENS: TokenInfo[]` containing the well-known ERC-20s `USDT`, `USDC`, `DAI`, `WBTC`, `WETH`, `LINK`, `UNI`, `MATIC` on Ethereum mainnet. The `formatTokenBalance(rawBalance, decimals)` helper SHALL produce a decimal string with sign + trailing-zero trim; `decimals === 0` SHALL return the bigint as-is.
|
||||
|
||||
#### Scenario: preload populates COMMON_TOKENS
|
||||
|
||||
- **WHEN** `new TokenRegistry()` is constructed and `size` is read
|
||||
- **THEN** `size === COMMON_TOKENS.length`
|
||||
|
||||
#### Scenario: getToken is case-insensitive on address
|
||||
|
||||
- **WHEN** a token is added with address `"0xAbC"` and `getToken("ethereum", "0xabc")` is called
|
||||
- **THEN** the token is returned
|
||||
|
||||
#### Scenario: searchBySymbol is case-insensitive
|
||||
|
||||
- **WHEN** `searchBySymbol("usd")` is called on a preloaded registry
|
||||
- **THEN** the result includes both `USDT` and `USDC`
|
||||
|
||||
#### Scenario: formatTokenBalance handles negatives and zeros
|
||||
|
||||
- **WHEN** `formatTokenBalance(-1500000n, 6)` and `formatTokenBalance(0n, 18)` are called
|
||||
- **THEN** the results are `"-1.5"` and `"0"` respectively
|
||||
|
||||
**Verified by:** `src/tokens/token-registry.test.ts::"preloads common tokens by default"` AND `src/tokens/token-registry.test.ts::"retrieves token with case-insensitive address"` AND `src/tokens/token-registry.test.ts::"searches by symbol case-insensitively"` AND `src/tokens/token-registry.test.ts::"handles negative balances"` AND `src/tokens/token-data.test.ts::"COMMON_TOKENS contains all 8 well-known ERC-20s"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Transaction selector decoder + KNOWN_SELECTORS
|
||||
|
||||
The package SHALL export `KNOWN_SELECTORS: FunctionSignature[]` containing at minimum `transfer`, `approve`, `transferFrom`, `balanceOf`, `allowance`, `totalSupply`, `safeTransferFrom`, `setApprovalForAll`, each with its canonical 4-byte selector and signature. `lookupSelector(selector)` SHALL return the matching `FunctionSignature` (case-insensitive on the selector) or `undefined`. `extractSelector(calldata)` SHALL return the first 10 chars of the trimmed lowercased calldata when it matches `/^0x[0-9a-fA-F]{8,}$/`, otherwise `undefined`. `decodeCalldata(calldata)` SHALL compose `extractSelector` then `lookupSelector`. `decodeTxHash(hash)` SHALL return a `TxMetadata` with `hashType` ∈ `"evm" | "utxo" | "solana" | "unknown"` derived from the same matchers as `validateTxHash` and `hashHex` populated for evm/utxo paths.
|
||||
|
||||
#### Scenario: lookupSelector finds canonical ERC-20 transfer
|
||||
|
||||
- **WHEN** `lookupSelector("0xa9059cbb")` is called
|
||||
- **THEN** the result has `name === "transfer"` and `signature === "transfer(address,uint256)"`
|
||||
|
||||
#### Scenario: extractSelector rejects too-short or non-hex calldata
|
||||
|
||||
- **WHEN** `extractSelector("0xabcd")` and `extractSelector("not-hex-data")` are called
|
||||
- **THEN** both return `undefined`
|
||||
|
||||
#### Scenario: decodeTxHash classifies hash type
|
||||
|
||||
- **WHEN** `decodeTxHash("0x" + "a".repeat(64))` and `decodeTxHash("a".repeat(64))` are called
|
||||
- **THEN** the first has `hashType === "evm"` and the second has `hashType === "utxo"`
|
||||
|
||||
**Verified by:** `src/transaction/tx-decoder.test.ts::"finds transfer selector"` AND `src/transaction/tx-decoder.test.ts::"returns undefined for too short calldata"` AND `src/transaction/tx-decoder.test.ts::"distinguishes EVM (0x prefix) from UTXO"` AND `src/transaction/tx-decoder.test.ts::"contains essential ERC-20 selectors"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Unit conversion for ETH / BTC / SOL with bigint arithmetic and typed errors
|
||||
|
||||
The `convertUnits(value, fromUnit, toUnit, currency)` function SHALL convert between any two registered denominations for ETH (`wei`, `kwei`, `mwei`, `gwei`, `microether`, `milliether`, `ether`), BTC (`satoshi`, `microbitcoin`, `millibitcoin`, `bitcoin`), and SOL (`lamport`, `sol`). The `currency` argument SHALL be case-insensitive. The arithmetic SHALL use `bigint` internally. The function SHALL throw `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CURRENCY, currency })` for an unrecognised currency and `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_UNIT, currency, unit })` for an unrecognised denomination. The package SHALL export convenience wrappers `weiToEther` / `etherToWei` / `gweiToEther` / `satoshiToBitcoin` / `bitcoinToSatoshi` / `lamportToSol`. `getUnits(currency)` SHALL return the `CryptoUnit[]` registry for the currency or `[]` for unknown currencies.
|
||||
|
||||
#### Scenario: ether → wei produces 18-decimal expansion
|
||||
|
||||
- **WHEN** `convertUnits("1", "ether", "wei", "ETH")` is called
|
||||
- **THEN** the result is `"1000000000000000000"`
|
||||
|
||||
#### Scenario: satoshi → bitcoin produces 8-decimal contraction
|
||||
|
||||
- **WHEN** `convertUnits("1", "satoshi", "bitcoin", "BTC")` is called
|
||||
- **THEN** the result is `"0.00000001"`
|
||||
|
||||
#### Scenario: unknown currency raises CRYPTOCURRENCY.UNKNOWN_CURRENCY
|
||||
|
||||
- **WHEN** `convertUnits("1", "wei", "ether", "XYZ")` is called
|
||||
- **THEN** a `CryptocurrencyError` with `code === "CRYPTOCURRENCY.UNKNOWN_CURRENCY"` and `currency === "XYZ"` is thrown
|
||||
|
||||
#### Scenario: unknown unit raises CRYPTOCURRENCY.UNKNOWN_UNIT
|
||||
|
||||
- **WHEN** `convertUnits("1", "fake", "ether", "ETH")` is called
|
||||
- **THEN** a `CryptocurrencyError` with `code === "CRYPTOCURRENCY.UNKNOWN_UNIT"`, `currency === "ETH"`, and `unit === "fake"` is thrown
|
||||
|
||||
#### Scenario: getUnits returns the ETH registry
|
||||
|
||||
- **WHEN** `getUnits("ETH")` is called
|
||||
- **THEN** the result includes entries with `name === "wei"` and `name === "ether"`
|
||||
|
||||
**Verified by:** `src/units/converter.test.ts::"ether to wei"` AND `src/units/converter.test.ts::"small satoshi to bitcoin"` AND `src/units/converter.test.ts::"throws CRYPTOCURRENCY.UNKNOWN_CURRENCY on unknown currency"` AND `src/units/converter.test.ts::"throws CRYPTOCURRENCY.UNKNOWN_UNIT on unknown from unit"` AND `src/units/converter.test.ts::"returns ETH units"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per public module
|
||||
|
||||
The package's `package.json` `exports` map SHALL declare subpath entries for every public module so consumers can tree-shake: `.`, `./package.json`, `./address/validator`, `./units/converter`, `./chains/registry`, `./chains/chain-registry`, `./chains/chain-data`, `./tokens/token-registry`, `./tokens/token-data`, `./gas/gas-estimator`, `./transaction/tx-decoder`, `./types/crypto.types`, `./error-codes/error-codes`. Every subpath SHALL resolve to a built `.js` + `.d.ts` under `dist/`.
|
||||
|
||||
#### Scenario: every advertised subpath resolves after build
|
||||
|
||||
- **WHEN** the test imports each of the 11 non-root subpath exports via dynamic `import()`
|
||||
- **THEN** every import resolves and yields a module record with at least one named export
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every package.json subpath export resolves to a real module"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
116
openspec/archive/2026-05-17-add-cryptocurrency/tasks.md
Normal file
116
openspec/archive/2026-05-17-add-cryptocurrency/tasks.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Tasks — cryptocurrency
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `openspec/AGENTS.md` §0, §0a, §0b, §0c, §6a, §7, §13, §14 end-to-end
|
||||
- [x] 1.2 Read `../suaveplan/packages/finance/cryptocurrency/src/` — all 8 implementation files + 7 test files
|
||||
- [x] 1.3 Read `../suaveplan/enterprise/cryptocurrency.md` in full
|
||||
- [x] 1.4 Read `openspec/archive/2026-05-16-add-seed/` as the closest precedent (universal-tier, error-coded migration)
|
||||
- [x] 1.5 Draft `proposal.md` with parity checklist, source mapping, affected dependents
|
||||
- [x] 1.6 Draft `design.md` covering layout, error-code map, EIP-55 algorithm preservation, LoggerLike injection, tier purity
|
||||
- [x] 1.7 Draft `specs/cryptocurrency/spec.md` in OpenSpec `### Requirement: / #### Scenario:` form
|
||||
- [x] 1.8 Every requirement carries a `**Verified by:**` line naming concrete planned `it(...)` titles
|
||||
|
||||
## 2. Scaffolding
|
||||
|
||||
- [x] 2.1 Create `packages/finance/cryptocurrency/` mirroring the database/seed layout
|
||||
- [x] 2.2 Write `package.json` v0.0.0, `"sideEffects": false`, subpath exports for the 11 modules listed in design.md §2
|
||||
- [x] 2.3 Wire deps: `@suaveplan/error: workspace:^`, `@suaveplan/types: workspace:^`, `tslib: catalog:build`
|
||||
- [x] 2.4 Wire devDeps: `@suaveplan/testing: workspace:^`, `@vitest/coverage-istanbul: catalog:testing`, `typescript: catalog:`, `vite: catalog:build`, `vite-plugin-dts: catalog:build`, `vitest: catalog:testing`, `@types/node: catalog:`
|
||||
- [x] 2.5 Write `tsconfig.json`, `tsconfig.lib.json` extending the repo base
|
||||
- [x] 2.6 Write `vite.config.ts` (preserveModules build for every subpath export)
|
||||
- [x] 2.7 Write `vitest.config.ts` (Istanbul, 100/100/100/100 thresholds, forks pool)
|
||||
- [x] 2.8 Write `tests/setup/polyfills.ts` (empty — universal-tier needs no polyfills)
|
||||
- [x] 2.9 Write `README.md` (≥ 200 words, required sections per CLAUDE.md docs gate)
|
||||
|
||||
## 3. Implementation — types
|
||||
|
||||
- [x] 3.1 `src/types/crypto.types.ts` — every public type from upstream preserved verbatim
|
||||
- [x] 3.2 `src/types/crypto.types.md` — co-located doc per docs-coverage gate
|
||||
|
||||
## 4. Implementation — error codes
|
||||
|
||||
- [x] 4.1 `src/error-codes/error-codes.ts` — `defineCodes("CRYPTOCURRENCY", [5 codes])` registry, `CryptocurrencyError extends SuaveplanError<CryptocurrencyErrorCode>` with `chainId?` / `currency?` / `unit?` discriminants
|
||||
- [x] 4.2 `src/error-codes/error-codes.test.ts` — every code reachable via `CryptocurrencyError` construction; cause-chain preservation verified
|
||||
- [x] 4.3 `src/error-codes/error-codes.md`
|
||||
- [x] 4.4 `src/error-codes/index.ts` — explicit named re-exports
|
||||
|
||||
## 5. Implementation — address validation
|
||||
|
||||
- [x] 5.1 `src/address/validator.ts` — `validateAddress`, `validateAddressForChain`, `validateTxHash`, internal positional EIP-55 checksum (preserve upstream behaviour bit-for-bit)
|
||||
- [x] 5.2 `src/address/validator.test.ts` — port every upstream test case + tier-purity assertion
|
||||
- [x] 5.3 `src/address/validator.md`
|
||||
- [x] 5.4 `src/address/index.ts` — explicit named re-exports
|
||||
|
||||
## 6. Implementation — chain data + registries
|
||||
|
||||
- [x] 6.1 `src/chains/chain-data.ts` — `CHAINS: Record<ChainId, ChainInfo>` with 15 entries (preserve upstream verbatim)
|
||||
- [x] 6.2 `src/chains/chain-data.test.ts` — assert 15 entries, EVM-id correctness
|
||||
- [x] 6.3 `src/chains/chain-data.md`
|
||||
- [x] 6.4 `src/chains/registry.ts` — synchronous getters
|
||||
- [x] 6.5 `src/chains/registry.test.ts` — port upstream test cases
|
||||
- [x] 6.6 `src/chains/registry.md`
|
||||
- [x] 6.7 `src/chains/chain-registry.ts` — `MemoryChainRegistry` + `ChainRegistry`; throws → `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })`; constructor gains optional `LoggerLike`; emits `chain.registered` / `chain.lookup.miss` events
|
||||
- [x] 6.8 `src/chains/chain-registry.test.ts` — port upstream tests; add `UNKNOWN_CHAIN` rejection test; add LoggerLike recording test; add tier-purity assertion
|
||||
- [x] 6.9 `src/chains/chain-registry.md`
|
||||
- [x] 6.10 `src/chains/index.ts` — explicit named re-exports
|
||||
|
||||
## 7. Implementation — gas
|
||||
|
||||
- [x] 7.1 `src/gas/gas-estimator.ts` — preserve upstream verbatim
|
||||
- [x] 7.2 `src/gas/gas-estimator.test.ts` — port upstream cases
|
||||
- [x] 7.3 `src/gas/gas-estimator.md`
|
||||
- [x] 7.4 `src/gas/index.ts` — explicit named re-exports
|
||||
|
||||
## 8. Implementation — tokens
|
||||
|
||||
- [x] 8.1 `src/tokens/token-data.ts` — preserve upstream `COMMON_TOKENS` verbatim
|
||||
- [x] 8.2 `src/tokens/token-data.test.ts` — assert 8 tokens present + shape invariants
|
||||
- [x] 8.3 `src/tokens/token-data.md`
|
||||
- [x] 8.4 `src/tokens/token-registry.ts` — preserve `TokenRegistry` + `formatTokenBalance`
|
||||
- [x] 8.5 `src/tokens/token-registry.test.ts` — port upstream cases
|
||||
- [x] 8.6 `src/tokens/token-registry.md`
|
||||
- [x] 8.7 `src/tokens/index.ts` — explicit named re-exports
|
||||
|
||||
## 9. Implementation — transaction
|
||||
|
||||
- [x] 9.1 `src/transaction/tx-decoder.ts` — preserve upstream verbatim
|
||||
- [x] 9.2 `src/transaction/tx-decoder.test.ts` — port upstream cases
|
||||
- [x] 9.3 `src/transaction/tx-decoder.md`
|
||||
- [x] 9.4 `src/transaction/index.ts` — explicit named re-exports
|
||||
|
||||
## 10. Implementation — units
|
||||
|
||||
- [x] 10.1 `src/units/converter.ts` — preserve upstream conversion logic; replace plain `throw new Error(...)` with `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CURRENCY | UNKNOWN_UNIT })`
|
||||
- [x] 10.2 `src/units/converter.test.ts` — port upstream cases + error-code assertions + tier-purity assertion
|
||||
- [x] 10.3 `src/units/converter.md`
|
||||
- [x] 10.4 `src/units/index.ts` — explicit named re-exports
|
||||
|
||||
## 11. Root barrel & exports test
|
||||
|
||||
- [x] 11.1 `src/index.ts` — explicit named re-export of every symbol listed in design.md §2 (no `export *`)
|
||||
- [x] 11.2 `tests/exports.test.ts` — assert every advertised subpath export resolves at runtime
|
||||
|
||||
## 12. Pre-archive gates
|
||||
|
||||
- [x] 12.1 `bunx turbo lint --filter=@suaveplan/cryptocurrency` green; zero biome suppressions
|
||||
- [x] 12.2 `bunx turbo typecheck --filter=@suaveplan/cryptocurrency` green
|
||||
- [x] 12.3 `bunx turbo test --filter=@suaveplan/cryptocurrency` green; zero `.skip` / `.only` / `it.todo`
|
||||
- [x] 12.4 `bunx turbo test:coverage --filter=@suaveplan/cryptocurrency` green at 100/100/100/100 (Istanbul)
|
||||
- [x] 12.5 `bun scripts/openspec-conformance.ts @suaveplan/cryptocurrency` — exit 0
|
||||
- [x] 12.6 `bunx turbo build --filter=@suaveplan/cryptocurrency` green
|
||||
- [x] 12.7 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/finance/cryptocurrency/src/` → zero matches (rule §11 / §7)
|
||||
- [x] 12.8 Feature-barrel `export *` scan — zero matches outside exempt forms (rule §6a)
|
||||
- [x] 12.9 `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/cryptocurrency/src/` → zero matches (tier purity)
|
||||
- [x] 12.10 Banned-pattern scan zero matches (`@deprecated`, `TODO`, `FIXME`, `ts-ignore`, `ts-expect-error`, `.skip`, `.only`, `any`, `console.log`)
|
||||
- [x] 12.11 Every `### Requirement` in `specs/cryptocurrency/spec.md` checked off against the implementation
|
||||
- [x] 12.12 `bun scripts/folder-reorg/generate-manifest.ts` run; `MANIFEST.md` includes `@suaveplan/cryptocurrency`
|
||||
- [x] 12.13 `STATUS.md` `@suaveplan/cryptocurrency` row flipped from `–` to `✅`
|
||||
- [x] 12.14 `SUMMARY.md` updated with per-package paragraph
|
||||
|
||||
## 13. Fold + archive
|
||||
|
||||
- [x] 13.1 Fold `specs/cryptocurrency/spec.md` into `openspec/specs/cryptocurrency/spec.md` (canonical file created here for the first time)
|
||||
- [x] 13.2 Move `openspec/changes/add-cryptocurrency/` to `openspec/archive/2026-05-17-add-cryptocurrency/`
|
||||
Reference in New Issue
Block a user