first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# `finance`
Currency conversion, cryptocurrency utilities, and exchange-rate providers.
<!-- begin:auto-docs -->
## Packages in `finance`
13 packages.
| Package | Description |
|---|---|
| [`@suaveplan/cryptocurrency`](./cryptocurrency/README.md) | Universal cryptocurrency utilities for wallet address validation, unit conversion, chain registry, gas estimation, ERC-20 token registry, calldata / function-s… |
| [`@suaveplan/currency`](./currency/README.md) | Universal currency conversion engine — pluggable `CurrencyProvider` interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup, primary + f… |
| [`@suaveplan/currency-provider-binance`](./currency-provider-binance/README.md) | Binance public-ticker crypto-rates provider for `@suaveplan/currency`. Sibling package in the foundation-with-sibling-plugins shape: implements the `CurrencyPr… |
| [`@suaveplan/currency-provider-coingecko`](./currency-provider-coingecko/README.md) | Universal CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/currency` against the public `/sim… |
| [`@suaveplan/currency-provider-coinmarketcap`](./currency-provider-coinmarketcap/README.md) | CoinMarketCap crypto-rates sibling provider for `@suaveplan/currency`. One of the nine `@suaveplan/currency-provider-<name>` sibling packages spun out of the 2… |
| [`@suaveplan/currency-provider-cryptocompare`](./currency-provider-cryptocompare/README.md) | Universal CryptoCompare crypto-rates provider for `@suaveplan/currency`. Sibling provider package on top of the slim `@suaveplan/currency-providers` core (`htt… |
| [`@suaveplan/currency-provider-ecb`](./currency-provider-ecb/README.md) | Universal `@suaveplan/currency` rate provider for the European Central Bank's daily exchange-rates XML feed. Free, no API key required. Built on the slim `@sua… |
| [`@suaveplan/currency-provider-fixer`](./currency-provider-fixer/README.md) | Fixer.io fiat-rates sibling provider for the slim `@suaveplan/currency-providers` core. Implements the `CurrencyProvider` contract from `@suaveplan/currency`,… |
| [`@suaveplan/currency-provider-kraken`](./currency-provider-kraken/README.md) | Universal Kraken public-ticker rate provider for `@suaveplan/currency`. Wraps Kraken's free, no-API-key `/0/public/Ticker` endpoint into a `CurrencyProvider` s… |
| [`@suaveplan/currency-provider-open-exchange-rates`](./currency-provider-open-exchange-rates/README.md) | Universal `@suaveplan/currency` provider for openexchangerates.org. Routes `/latest.json` through the slim-core `httpRequest()` helper, validates the upstream… |
| [`@suaveplan/currency-provider-virtual`](./currency-provider-virtual/README.md) | In-memory, programmable virtual / game currency provider for `@suaveplan/currency`. Sibling of the `@suaveplan/currency-providers` slim plugin core. No HTTP, n… |
| [`@suaveplan/currency-providers`](./currency-providers/README.md) | Slim plugin core for `@suaveplan/currency` rate providers. Ships the `CurrencyProviderRegistry` (name-keyed lookup), the `defineCurrencyProvider` factory, the… |
| [`@suaveplan/money`](./money/README.md) | Universal precise currency and money arithmetic using integer minor-unit representation. All operations use `bigint` so consumers never hit floating-point roun… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Currency conversion, cryptocurrency utilities, and exchange-rate providers.

View File

@@ -0,0 +1,147 @@
# @suaveplan/cryptocurrency - Universal cryptocurrency utilities: wallet address validation across 15 chains with EIP-55 checksumming, unit conversion (ETH/BTC/SOL), chain registry with async adapter, EIP-1559 gas estimation, ERC-20 token registry, calldata/selector decoder, transaction hash parsing. Zero browser/Node deps; injectable LoggerLike; typed CryptocurrencyError hierarchy.
Universal cryptocurrency utilities for wallet address validation, unit conversion, chain registry, gas estimation, ERC-20 token registry, calldata / function-selector decoding, and transaction hash parsing. Zero browser or Node-specific dependencies — runs unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. Not a wallet, not a signing library — just pure utility primitives.
<!-- begin:auto-docs -->
## Documentation tree
- [address](./src/address/validator.md) — Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for c…
- [validator](./src/address/validator.md) — Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for c…
- [chain-data](./src/chains/chain-data.md) — Holds the canonical `CHAINS` constant: a `Record<ChainId, ChainInfo>` containing one entry per supported mainnet networ…
- [chain-registry](./src/chains/chain-registry.md) — Provides the async chain-registry façade for callers that need dynamic chain data — custom mainnet sets, runtime regist…
- [registry](./src/chains/registry.md) — Exposes the synchronous fast-path over the built-in `CHAINS` constant. Every helper here is a pure function that reads…
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` clas…
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` clas…
- [gas](./src/gas/gas-estimator.md) — Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values…
- [gas-estimator](./src/gas/gas-estimator.md) — Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values…
- [token-data](./src/tokens/token-data.md) — Provides the `COMMON_TOKENS` constant: a curated list of eight well-known ERC-20 tokens on Ethereum mainnet — USDT, USD…
- [token-registry](./src/tokens/token-registry.md) — Provides the `TokenRegistry` class for adding, retrieving, and searching ERC-20 / token information across chains, plus…
- [transaction](./src/transaction/tx-decoder.md) — Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` c…
- [tx-decoder](./src/transaction/tx-decoder.md) — Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` c…
- [units](./src/units/converter.md) — Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates…
- [converter](./src/units/converter.md) — Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/cryptocurrency
```
## Quick start
```ts
import {
estimateGas,
formatGwei,
getChain,
getExplorerTxUrl,
satoshiToBitcoin,
validateAddress,
weiToEther,
} from "@suaveplan/cryptocurrency";
// Multi-chain address validation with auto-detection
const r = validateAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28");
// { valid: true, normalized: "0x...", chains: ["ethereum", "polygon", ...] }
// Unit conversion (bigint-pure)
weiToEther("1000000000000000000"); // "1"
satoshiToBitcoin("100000000"); // "1"
// Chain metadata + explorer URLs
const eth = getChain("ethereum");
// { id: "ethereum", evmChainId: 1, decimals: 18, ... }
const url = getExplorerTxUrl("ethereum", "0xabc...");
// EIP-1559 gas estimation
const estimate = estimateGas({
gasLimit: 21000n,
baseFeePerGas: 30000000000n,
maxPriorityFeePerGas: 2000000000n,
});
formatGwei(estimate.gasPrice); // "32"
```
## Features
- **Address validation across 15 chains** (Bitcoin, Ethereum, Solana, Polygon, Arbitrum, Optimism, Avalanche, BSC, Base, TRON, Litecoin, Dogecoin, Cardano, Polkadot, Cosmos) via `validateAddress` / `validateAddressForChain`.
- **Transaction hash classification** (EVM / UTXO / Solana) via `validateTxHash` and `decodeTxHash`.
- **EIP-55 checksumming** for EVM addresses (simplified positional algorithm preserved from upstream; full keccak256 deferred to a follow-up change).
- **Static chain registry** with `getChain` / `getAllChainIds` / `getEvmChains` / `getChainByEvmId` / explorer URL helpers.
- **Async chain registry** (`ChainRegistry` + `MemoryChainRegistry`) supporting custom adapters and typed `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })`.
- **Bigint-pure unit conversion** across ETH / BTC / SOL (`convertUnits`, `weiToEther`, `etherToWei`, `gweiToEther`, `satoshiToBitcoin`, `bitcoinToSatoshi`, `lamportToSol`, `getUnits`).
- **EIP-1559 gas estimation** (`estimateGas`, `calculateMaxFeePerGas`, `calculateEffectiveGasPrice`, `formatGwei`, `estimateTxCostEth`).
- **ERC-20 token registry** (`TokenRegistry`, `COMMON_TOKENS`, `formatTokenBalance`) — case-insensitive address lookups, substring search by symbol / name.
- **Calldata + function-selector decoder** (`KNOWN_SELECTORS`, `lookupSelector`, `extractSelector`, `decodeCalldata`).
- **Typed `CryptocurrencyError` hierarchy** with `CRYPTOCURRENCY.*` code registry and optional `chainId` / `currency` / `unit` discriminants.
- **Optional `LoggerLike` injection** on `ChainRegistry` (no-op default) emitting `chain.registered` / `chain.lookup.miss` events.
- **Tree-shakeable subpath exports** — import only what you use.
- **Universal tier** — no `node:*`, no `bun:*`, no `fs` / `path` / `crypto` imports.
## Modules
Co-located docs for every implementation file:
- [`src/address/validator.md`](./src/address/validator.md) — `validateAddress`, `validateAddressForChain`, `validateTxHash`.
- [`src/chains/chain-data.md`](./src/chains/chain-data.md) — `CHAINS` constant.
- [`src/chains/chain-registry.md`](./src/chains/chain-registry.md) — `ChainRegistry`, `MemoryChainRegistry`, `LoggerLike` integration.
- [`src/chains/registry.md`](./src/chains/registry.md) — synchronous getters.
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `CRYPTOCURRENCY_CODES`, `CryptocurrencyError`.
- [`src/gas/gas-estimator.md`](./src/gas/gas-estimator.md) — EIP-1559 gas helpers.
- [`src/tokens/token-data.md`](./src/tokens/token-data.md) — `COMMON_TOKENS`.
- [`src/tokens/token-registry.md`](./src/tokens/token-registry.md) — `TokenRegistry`, `formatTokenBalance`.
- [`src/transaction/tx-decoder.md`](./src/transaction/tx-decoder.md) — calldata / selector / tx-hash decoder.
- [`src/types/crypto.types.md`](./src/types/crypto.types.md) — public type contract.
- [`src/units/converter.md`](./src/units/converter.md) — generic + convenience unit conversion.
## Subpath exports
Every public module ships its own subpath so consumers can tree-shake:
```ts
import { validateAddress } from "@suaveplan/cryptocurrency/address/validator";
import { CHAINS } from "@suaveplan/cryptocurrency/chains/chain-data";
import { ChainRegistry } from "@suaveplan/cryptocurrency/chains/chain-registry";
import { getChain } from "@suaveplan/cryptocurrency/chains/registry";
import { CRYPTOCURRENCY_CODES } from "@suaveplan/cryptocurrency/error-codes/error-codes";
import { estimateGas } from "@suaveplan/cryptocurrency/gas/gas-estimator";
import { COMMON_TOKENS } from "@suaveplan/cryptocurrency/tokens/token-data";
import { TokenRegistry } from "@suaveplan/cryptocurrency/tokens/token-registry";
import { decodeCalldata } from "@suaveplan/cryptocurrency/transaction/tx-decoder";
import type { ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
import { convertUnits } from "@suaveplan/cryptocurrency/units/converter";
```
## Error handling
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
convertUnits,
} from "@suaveplan/cryptocurrency";
try {
convertUnits("1", "wei", "ether", "XYZ");
} catch (err) {
if (err instanceof CryptocurrencyError) {
if (err.code === CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY) {
console.error(`unsupported currency: ${err.currency}`);
}
}
}
```
## What this package is not
- **Not a wallet** — no key management, no transaction signing.
- **Not an RPC client** — no on-chain calls; the `ChainRegistry` adapter contract lets you wire your own RPC layer.
- **Not an ABI decoder** — `decodeCalldata` only recognises 4-byte selectors. Full ABI decoding (dynamic types, tuples) lands in a follow-up change.
- **Not an ENS resolver** — name-resolution lands in a separate package.
- **Not RFC-correct EIP-55** — uses a simplified positional checksum preserved from upstream; full keccak256 lands in a follow-up change.
## License
MIT

View File

@@ -0,0 +1,84 @@
# validator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for chain auto-detection from an address string, `validateAddressForChain` for chain-locked validation, and `validateTxHash` for transaction-hash classification. All three return structured `AddressValidation` / `TxHashValidation` records rather than throwing, so callers can route on success / failure without try-catch ceremony. The validators recognise 11 distinct address shapes across 15 chains and three transaction-hash shapes across the EVM / UTXO / Solana families.
This module is the broadest consumer-facing entry point in the package — every wallet, payment, or explorer integration starts here. The structured-return contract (no throws) was inherited from upstream and preserved so existing consumers can migrate without changing call sites.
## Features
- 15-chain address detection (`validateAddress`) covering EVM hex, Bitcoin legacy + Segwit, Litecoin legacy + Segwit, Solana base58, TRON base58, Dogecoin base58, Cardano bech32, Cosmos bech32, Polkadot ss58.
- Chain-locked validation (`validateAddressForChain`) narrowing the result to a single id on success.
- Transaction hash classification (`validateTxHash`) returning the set of chains a hash could belong to.
- Positional EIP-55 checksumming on EVM `normalized` output (preserved bit-for-bit from upstream — see Implementation Notes for the keccak256 deferral).
- Leading/trailing whitespace trimmed on every input.
- Structured failure returns (`{ valid: false, chains: [], error: "..." }`) — no throws.
- Universal — no `node:*` / `bun:*` / `fs` / `path` / `crypto` imports.
## Usage: Basic
```ts
import { validateAddress, validateTxHash } from "@suaveplan/cryptocurrency";
const r1 = validateAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28");
// { valid: true, chains: ["ethereum", "polygon", ...], normalized: "0x..." }
const r2 = validateTxHash(`0x${"a".repeat(64)}`);
// { valid: true, chains: ["ethereum", "polygon", ...] }
```
## Usage: Advanced
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
type ChainId,
validateAddressForChain,
} from "@suaveplan/cryptocurrency";
function requireAddressForChain(address: string, chainId: ChainId): string {
const result = validateAddressForChain(address, chainId);
if (!result.valid) {
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.INVALID_ADDRESS,
result.error ?? `Invalid address for ${chainId}`,
{ chainId }
);
}
return result.normalized ?? address;
}
const normalized = requireAddressForChain(userInput, "ethereum");
```
This pattern lets application-tier consumers turn the validators' structured returns into typed throws when their architecture prefers exceptions. The exposed `CRYPTOCURRENCY.INVALID_ADDRESS` code makes the failure mode programmatically dispatchable.
## API Reference
| Export | Signature | Notes |
|---|---|---|
| `validateAddress` | `(address: string) => AddressValidation` | auto-detects chains; populates `normalized` (positional-EIP-55 checksum) for EVM matches |
| `validateAddressForChain` | `(address: string, chainId: ChainId) => AddressValidation` | narrows to a single chain on success; propagates `validateAddress`'s failure verbatim |
| `validateTxHash` | `(hash: string) => TxHashValidation` | classifies EVM / UTXO / Solana hash shapes |
### Failure messages
- Empty / null / non-string `address` → `"Address must be a non-empty string"`
- No pattern matches → `"Address does not match any known format"`
- Address validates but not for the requested chain → `` `Address is not valid for ${chainId}` ``
- Empty / null / non-string `hash` → `"Hash must be a non-empty string"`
- No hash pattern matches → `"Hash does not match any known format"`
## Implementation Notes
- The `ADDRESS_PATTERNS` table is the single source of truth for address detection. Adding a chain means adding one entry to the table and one entry to the `ChainId` union — no other code changes required.
- The EIP-55 checksum implementation is **NOT** RFC-correct. It is a simplified positional algorithm (`even-index alpha → uppercase, odd-index alpha → lowercase`) preserved bit-for-bit from upstream so consumers depending on its output keep working. The audit (`enterprise/cryptocurrency.md` L57-58) flags this gap; the swap to keccak256 lands in a follow-up change gated on `@suaveplan/crypto-hash` being wired in.
- The transaction-hash classifier returns plain arrays (not the readonly tuples it stores internally) so callers can freely mutate the result without affecting subsequent calls.
- All regex patterns are anchored (`^...$`) to prevent partial-string matches.
- The validators never throw — the only `throw` pattern in the entire module would be unreachable, so the public contract is "data in, structured data out, no exceptions."

View File

@@ -0,0 +1,62 @@
# chain-data
## Purpose
Holds the canonical `CHAINS` constant: a `Record<ChainId, ChainInfo>` containing one entry per supported mainnet network. This is the single source of truth for every chain-related lookup in the package — the synchronous helpers in `registry.ts`, the default seed for `MemoryChainRegistry`, and the `validateAddress` chain-detection table all derive their data from here.
The constant is shipped as a plain object literal rather than being constructed at runtime, so consumers' bundlers can dead-code-eliminate the entries they don't use when importing via the `./chains/chain-data` subpath export.
## Features
- Exactly 15 entries: bitcoin, ethereum, solana, polygon, arbitrum, optimism, avalanche, bsc, base, tron, litecoin, dogecoin, cardano, polkadot, cosmos.
- EVM chains carry the canonical mainnet `evmChainId` (Ethereum 1, BSC 56, Polygon 137, Optimism 10, Base 8453, Arbitrum 42161, Avalanche 43114).
- Non-EVM chains omit `evmChainId`.
- Every entry declares `id`, `name`, `nativeSymbol`, `addressType`, `explorerUrl`, `decimals`, `testnet: false`.
- Explorer URLs are base templates — the synchronous helpers in `registry.ts` and the async `ChainRegistry` append `/address/<addr>` or `/tx/<hash>` to produce concrete URLs.
- Pure data export — no runtime initialisation, no side effects.
## Usage: Basic
```ts
import { CHAINS } from "@suaveplan/cryptocurrency";
const eth = CHAINS.ethereum;
// { id: "ethereum", name: "Ethereum", evmChainId: 1, decimals: 18, ... }
```
## Usage: Advanced
```ts
import { CHAINS } from "@suaveplan/cryptocurrency";
import type { ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
function evmChainsByDecimals(): Map<number, ChainInfo[]> {
const out = new Map<number, ChainInfo[]>();
for (const chain of Object.values(CHAINS)) {
if (chain.evmChainId === undefined) continue;
const bucket = out.get(chain.decimals) ?? [];
bucket.push(chain);
out.set(chain.decimals, bucket);
}
return out;
}
const grouped = evmChainsByDecimals();
// Map { 18 => [ethereum, polygon, arbitrum, optimism, avalanche, bsc, base] }
```
Iterating `Object.values(CHAINS)` is the canonical way to enumerate the registry; the `ChainId` union and the `Record` constraint guarantee TypeScript sees every key as a literal string.
## API Reference
| Export | Type | Notes |
|---|---|---|
| `CHAINS` | `Record<ChainId, ChainInfo>` | 15-entry mainnet registry |
## Implementation Notes
- The constant is `const` but **not** `as const` — the entries are mutable to allow consumers to spread-merge their own overrides (`{ ...CHAINS, custom: { ... } }`). If callers want a frozen view they can wrap with `Object.freeze` themselves.
- The object literal layout is alphabetised by mnemonic within each group (Bitcoin / EVM / Solana / others) for readability; the iteration order matters for `validateAddress`'s chain-detection table because the EVM regex matches a superset of single-EVM-chain addresses.
- Adding a new mainnet chain requires four edits: extend the `ChainId` union in `types/crypto.types.ts`, add a `CHAINS` entry here, add a row to the `ADDRESS_PATTERNS` table in `address/validator.ts`, and extend the chain-data test's `EXPECTED_IDS` list.
- Testnet support is deliberately scoped out of this migration — testnets land in `add-cryptocurrency-testnets` per `enterprise/cryptocurrency.md` §4.
- The `explorerUrl` values are base hosts only; the trailing path segments (`/address/...`, `/tx/...`) are added by `registry.ts` so they can be swapped without rewriting the table.

View File

@@ -0,0 +1,126 @@
# chain-registry
## Purpose
Provides the async chain-registry façade for callers that need dynamic chain data — custom mainnet sets, runtime registrations, DB-backed or HTTP-backed adapters. `MemoryChainRegistry` is the default in-memory implementation of the `ChainRegistryAdapter` contract; `ChainRegistry` is a thin façade over an injected adapter that adds an `LoggerLike` integration and raises typed `CryptocurrencyError` for missing-chain explorer-URL lookups.
Use this module when the synchronous `registry.ts` helpers (which read from the built-in `CHAINS` constant) are insufficient — for example, when callers want to register their own chain mid-process, when chain data is loaded from a backend, or when typed-error dispatch is preferred over `undefined` checks.
## Features
- `MemoryChainRegistry implements ChainRegistryAdapter` — `Map`-backed in-memory storage; preloads built-in `CHAINS` or accepts a custom `ChainInfo[]` seed.
- `ChainRegistry` — façade over any `ChainRegistryAdapter`; defaults to a fresh `MemoryChainRegistry` when no adapter is provided.
- Optional `LoggerLike` injection on `ChainRegistry` constructor — no-op default.
- Operational events: `chain.registered` (info) on `registerChain`; `chain.lookup.miss` (debug) when an explorer URL is requested for an unknown chain.
- `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })` raised by `getExplorerAddressUrl` / `getExplorerTxUrl` for unknown chains — typed dispatch instead of `undefined`.
- Universal — no `node:*` / `bun:*` / `fs` / `path` / `crypto` imports.
## Usage: Basic
```ts
import { ChainRegistry } from "@suaveplan/cryptocurrency";
const registry = new ChainRegistry();
const eth = await registry.getChain("ethereum");
const url = await registry.getExplorerTxUrl("ethereum", "0xabc...");
```
## Usage: Advanced
```ts
import {
ChainRegistry,
type ChainInfo,
type ChainRegistryAdapter,
MemoryChainRegistry,
} from "@suaveplan/cryptocurrency";
import type { LoggerLike } from "@suaveplan/types";
// Wrap an HTTP-backed source as a ChainRegistryAdapter
class HttpChainAdapter implements ChainRegistryAdapter {
public constructor(private readonly baseUrl: string) {}
public async getChain(id: ChainInfo["id"]): Promise<ChainInfo | undefined> {
const res = await fetch(`${this.baseUrl}/chains/${id}`);
return res.ok ? (res.json() as Promise<ChainInfo>) : undefined;
}
public async getAllChains(): Promise<ChainInfo[]> {
const res = await fetch(`${this.baseUrl}/chains`);
return res.json() as Promise<ChainInfo[]>;
}
public async getChainByEvmId(id: number): Promise<ChainInfo | undefined> {
const res = await fetch(`${this.baseUrl}/chains/evm/${id}`);
return res.ok ? (res.json() as Promise<ChainInfo>) : undefined;
}
public async registerChain(): Promise<void> {
throw new Error("read-only adapter");
}
}
const logger: LoggerLike = {
debug: (m, c) => console.log("[debug]", m, c),
info: (m, c) => console.log("[info]", m, c),
warn: (m, c) => console.warn("[warn]", m, c),
error: (m, c) => console.error("[error]", m, c),
};
const remote = new ChainRegistry(
new HttpChainAdapter("https://chains.example.com"),
logger
);
try {
await remote.getExplorerTxUrl("unsupported" as ChainInfo["id"], "0xabc");
} catch (err) {
// CryptocurrencyError { code: "CRYPTOCURRENCY.UNKNOWN_CHAIN", chainId: "unsupported" }
}
// MemoryChainRegistry also accepts a custom seed for tests
const empty = new MemoryChainRegistry([]);
```
## API Reference
### `MemoryChainRegistry`
| Method | Signature | Notes |
|---|---|---|
| constructor | `(initialChains?: ChainInfo[])` | preloads built-in `CHAINS` when no seed is provided |
| `getChain` | `(id: ChainId) => Promise<ChainInfo | undefined>` | |
| `getAllChains` | `() => Promise<ChainInfo[]>` | returns a snapshot array |
| `getChainByEvmId` | `(evmChainId: number) => Promise<ChainInfo | undefined>` | |
| `registerChain` | `(chain: ChainInfo) => Promise<void>` | upsert by `chain.id` |
### `ChainRegistry`
| Method | Signature | Notes |
|---|---|---|
| constructor | `(adapter?: ChainRegistryAdapter, logger?: LoggerLike)` | defaults to fresh `MemoryChainRegistry` + no-op logger |
| `getChain` | `(id: ChainId) => Promise<ChainInfo | undefined>` | forwards to adapter |
| `getAllChains` | `() => Promise<ChainInfo[]>` | forwards to adapter |
| `getChainByEvmId` | `(evmChainId: number) => Promise<ChainInfo | undefined>` | forwards to adapter |
| `registerChain` | `(chain: ChainInfo) => Promise<void>` | forwards to adapter; logs `chain.registered` (info) |
| `getEvmChains` | `() => Promise<ChainInfo[]>` | filter of `getAllChains()` |
| `getExplorerAddressUrl` | `(chainId: ChainId, address: string) => Promise<string>` | raises `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })` on missing chains; logs `chain.lookup.miss` (debug) |
| `getExplorerTxUrl` | `(chainId: ChainId, txHash: string) => Promise<string>` | same error/logger behaviour as above |
### Error contract
- `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })` raised by both explorer URL methods when the adapter returns `undefined`.
### Logger events
- `chain.registered` — info — `{ chainId, evmChainId }` — emitted on every successful `registerChain` call.
- `chain.lookup.miss` — debug — `{ chainId }` — emitted before raising `UNKNOWN_CHAIN`.
## Implementation Notes
- `MemoryChainRegistry` stores entries in a `Map<string, ChainInfo>` keyed by `chain.id`. The key type is `string` (not `ChainId`) so callers can register chains whose ids are not in the built-in `ChainId` union — useful for sidechains or testnets registered at runtime. Reading via `getChain(id)` still requires a `ChainId` because that's the contract; consumers wanting to look up runtime-registered ids should cast (`"sepolia" as ChainId`) and accept the typing risk.
- `ChainRegistry`'s constructor accepts the adapter and logger as positional optional parameters in that order. This mirrors the pattern used by sibling packages (`SeedRunner(registry, store, logger?)`) so reviewers can spot the wiring quickly.
- The default `NOOP_LOGGER` has empty method bodies, not `() => undefined`, so the V8 inliner can eliminate the call entirely under `-O3` profiles.
- `getExplorerAddressUrl` and `getExplorerTxUrl` are intentionally duplicated rather than refactored to a shared helper — the duplication is two lines, the call sites are the entire reason the typed error exists, and extracting would obscure the error's discriminant context.
- The `chain.lookup.miss` debug event is emitted **before** the throw so structured-log consumers can correlate the miss with the error. The throw is unconditional; consumers wanting silent-undefined behaviour should use the synchronous `registry.ts` helpers instead, which return `undefined` from `getChain` directly.
- Tier purity: only `@suaveplan/types` (types-only) and `@suaveplan/error` (universal) imports — no `node:*`, no `bun:*`.

View File

@@ -0,0 +1,71 @@
# registry (synchronous chain getters)
## Purpose
Exposes the synchronous fast-path over the built-in `CHAINS` constant. Every helper here is a pure function that reads from `CHAINS` and returns either the matching `ChainInfo` or a derived value (filtered list, concatenated explorer URL). Use this module when you only need read access to the static mainnet registry — it has zero allocation overhead beyond the returned arrays.
For dynamic registries (custom chain sets, DB-backed lookups, runtime registrations) use `ChainRegistry` from `./chain-registry` instead.
## Features
- `getChain(id)` — direct lookup against the built-in `CHAINS` constant.
- `getAllChainIds()` — returns all 15 ids in registry order.
- `getEvmChains()` — filtered view of EVM-compatible chains.
- `getChainByEvmId(evmChainId)` — reverse lookup by canonical EVM chain id.
- `getExplorerAddressUrl(chainId, address)` — `${explorerUrl}/address/${address}`.
- `getExplorerTxUrl(chainId, txHash)` — `${explorerUrl}/tx/${txHash}`.
- Pure functions — no internal state, no side effects, safe to call from any context.
## Usage: Basic
```ts
import { getChain, getExplorerTxUrl } from "@suaveplan/cryptocurrency";
const eth = getChain("ethereum");
const url = getExplorerTxUrl("ethereum", "0xabc...");
```
## Usage: Advanced
```ts
import {
getChainByEvmId,
getEvmChains,
getExplorerAddressUrl,
} from "@suaveplan/cryptocurrency";
function buildEvmDashboard(evmChainId: number, address: string) {
const chain = getChainByEvmId(evmChainId);
if (!chain) return null;
return {
chain: chain.name,
symbol: chain.nativeSymbol,
explorer: getExplorerAddressUrl(chain.id, address),
siblings: getEvmChains()
.filter((c) => c.id !== chain.id)
.map((c) => c.name),
};
}
const dashboard = buildEvmDashboard(137, "0xabc...");
// { chain: "Polygon", symbol: "POL", explorer: "...", siblings: ["Ethereum", "Arbitrum One", ...] }
```
## API Reference
| Export | Signature | Notes |
|---|---|---|
| `getChain` | `(id: ChainId) => ChainInfo` | direct, total — no `undefined` because `ChainId` is a closed union over the built-in keys |
| `getAllChainIds` | `() => ChainId[]` | order is the `CHAINS` literal's declaration order |
| `getEvmChains` | `() => ChainInfo[]` | filtered by `evmChainId !== undefined` |
| `getChainByEvmId` | `(evmChainId: number) => ChainInfo | undefined` | reverse lookup; returns `undefined` for unknown ids |
| `getExplorerAddressUrl` | `(chainId: ChainId, address: string) => string` | template + `/address/<addr>` |
| `getExplorerTxUrl` | `(chainId: ChainId, txHash: string) => string` | template + `/tx/<hash>` |
## Implementation Notes
- `getChain` is total because `ChainId` is a closed union over the keys of `CHAINS`. Passing a string asserted as `ChainId` (`"unknown" as ChainId`) bypasses the type check and yields `undefined` at runtime — callers responsible for the cast are responsible for the consequence.
- `getAllChainIds` returns a fresh array on each call so callers can mutate it (push/sort/filter) without affecting the registry.
- `getEvmChains` allocates a fresh array each call — for hot paths, cache the result.
- The explorer URL helpers do **not** URL-encode the address or tx-hash argument. Hex addresses and hashes are safe in URLs by definition; if the caller passes a value containing reserved characters, the resulting URL will be malformed. This is intentional — encoding would be lossy for hex inputs (e.g. converting `0x` to `0x` is a no-op but obscures intent).
- For dynamic chain registries (custom mainnet sets, DB-backed adapters, runtime additions), use `ChainRegistry` and `MemoryChainRegistry` from `./chain-registry` — they implement the async `ChainRegistryAdapter` contract and raise `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })` on missing chains.

View File

@@ -0,0 +1,94 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` class that wraps every failure path. The registry uses `defineCodes("CRYPTOCURRENCY", [...])` to produce a frozen, literal-typed map (`{ readonly UNKNOWN_CHAIN: "CRYPTOCURRENCY.UNKNOWN_CHAIN", ... }`) so consumers can dispatch programmatically on `err.code === CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN` with full TypeScript narrowing.
Centralising the registry guarantees the error contract is single-sourced — every throw site in the package picks from the same five-code set, and the exposed `CryptocurrencyErrorCode` union travels with consumers' code so a future code addition triggers an exhaustiveness check at every switch site.
## Features
- Five codes covering every distinct failure mode in the migrated surface: `UNKNOWN_CHAIN`, `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT`, `INVALID_ADDRESS`, `INVALID_HASH`.
- `CryptocurrencyError extends SuaveplanError<CryptocurrencyErrorCode>` with optional `chainId` / `currency` / `unit` discriminants for diagnostics.
- `CryptocurrencyErrorCode` literal union exported for consumer narrowing.
- `CryptocurrencyErrorOptions` extends `SuaveplanErrorOptions` so `cause`, `context`, `traceId`, and `clock` are all available.
- The registry is `Object.freeze`d so consumers cannot mutate the code map.
## Usage: Basic
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
} from "@suaveplan/cryptocurrency";
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN,
"Unknown chain: ethereum",
{ chainId: "ethereum" }
);
```
## Usage: Advanced
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
type CryptocurrencyErrorCode,
} from "@suaveplan/cryptocurrency";
function describeFailure(err: unknown): string {
if (!(err instanceof CryptocurrencyError)) return "non-crypto error";
const code: CryptocurrencyErrorCode = err.code;
switch (code) {
case CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN:
return `unknown chain: ${err.chainId ?? "<unset>"}`;
case CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY:
return `unknown currency: ${err.currency ?? "<unset>"}`;
case CRYPTOCURRENCY_CODES.UNKNOWN_UNIT:
return `unknown unit '${err.unit}' for ${err.currency}`;
case CRYPTOCURRENCY_CODES.INVALID_ADDRESS:
return `invalid address${err.chainId ? ` for ${err.chainId}` : ""}`;
case CRYPTOCURRENCY_CODES.INVALID_HASH:
return `invalid hash${err.chainId ? ` for ${err.chainId}` : ""}`;
}
}
try {
// some package call
} catch (err) {
console.error(describeFailure(err));
}
```
The exhaustive switch above will fail to compile if a new code is added without a matching arm — that is the point of exporting `CryptocurrencyErrorCode`.
## API Reference
| Export | Kind | Notes |
|---|---|---|
| `CRYPTOCURRENCY_CODES` | const | frozen `{ readonly <REASON>: "CRYPTOCURRENCY.<REASON>" }` map |
| `CryptocurrencyErrorCode` | type | union of every registered code's string literal |
| `CryptocurrencyError` | class | `extends SuaveplanError<CryptocurrencyErrorCode>`; constructor `(code, message, options?)` |
| `CryptocurrencyErrorOptions` | interface | `extends SuaveplanErrorOptions`; adds optional `chainId`, `currency`, `unit` |
### `CryptocurrencyError` discriminants
- `chainId?: ChainId` — set on `UNKNOWN_CHAIN`, `INVALID_ADDRESS`, `INVALID_HASH` raises.
- `currency?: string` — set on `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT` raises.
- `unit?: string` — set on `UNKNOWN_UNIT` raises.
All discriminants are read-only and `undefined` when not supplied — consumers should narrow with `if (err.chainId)` rather than asserting non-null.
## Implementation Notes
- `INVALID_ADDRESS` and `INVALID_HASH` are exposed in the registry even though the package's own validators return structured `AddressValidation` / `TxHashValidation` records rather than throwing. The codes are there for application-tier consumers that want to wrap a failed validation in a typed throw. They are exercised by direct construction in `error-codes.test.ts`, satisfying the conformance rule (§13 step 4 in `AGENTS.md`) that every registered code must be reachable from a call site somewhere in `src/`.
- `defineCodes` produces a `Object.freeze`d record — mutating `CRYPTOCURRENCY_CODES` throws in strict mode. Consumers must rely on the literal types, not runtime extension.
- `CryptocurrencyError` extends `SuaveplanError` (rule §63 of `CLAUDE.md`) so every error in the package participates in cause-chain traversal, OTel span events, redaction, and i18n message resolution.
- The error's `name` field is automatically set to `"CryptocurrencyError"` by `SuaveplanError`'s use of `new.target.name`.
- The optional discriminants are assigned via `if (options.X !== undefined)` so the field stays `undefined` (and serialises away from JSON) when not provided.

View File

@@ -0,0 +1,119 @@
# gas-estimator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values so callers never lose precision at the JavaScript `number` boundary — gas costs in wei routinely exceed `Number.MAX_SAFE_INTEGER` (2^53 - 1) so passing them as `number` is a bug waiting to happen. The module covers the four canonical operations: estimating a full `GasEstimate` from a single set of parameters, computing the EIP-1559 ceiling (`2 * baseFee + priority`), computing the effective gas price (`min(maxFee, baseFee + priority)`), and formatting wei values as human-readable gwei or ETH decimal strings.
This module is consumed by wallet UIs, transaction-builder libraries, and analytics dashboards. The bigint-everywhere contract is the headline guarantee.
## Features
- `estimateGas(params)` — full EIP-1559 estimator returning `GasEstimate` (gasLimit, gasPrice, maxFeePerGas, maxPriorityFeePerGas, totalCost). Honours an optional `legacyGasPrice` override for non-EIP-1559 transactions.
- `calculateMaxFeePerGas(baseFee, priorityFee)` — EIP-1559 ceiling formula.
- `calculateEffectiveGasPrice(baseFee, maxFee, priorityFee)` — `min(maxFee, baseFee + priorityFee)`.
- `formatGwei(weiValue)` — sign-preserving decimal gwei string with trailing-zero trim.
- `estimateTxCostEth(gasLimit, gasPriceWei)` — decimal ETH string (18-decimal contraction with trim).
- 100% `bigint` arithmetic — no `Number` conversion anywhere on the hot path.
- Universal — no `node:*` / `bun:*` / `fs` / `crypto` imports.
## Usage: Basic
```ts
import { estimateGas, formatGwei } from "@suaveplan/cryptocurrency";
const estimate = estimateGas({
gasLimit: 21000n,
baseFeePerGas: 30000000000n, // 30 gwei
maxPriorityFeePerGas: 2000000000n, // 2 gwei
});
console.log(formatGwei(estimate.gasPrice)); // "32"
console.log(estimate.totalCost); // 672000000000000n wei = 0.000672 ETH
```
## Usage: Advanced
```ts
import {
calculateEffectiveGasPrice,
calculateMaxFeePerGas,
estimateGas,
estimateTxCostEth,
} from "@suaveplan/cryptocurrency";
interface BlockHeader {
baseFeePerGas: bigint;
}
interface UserPreferences {
priorityFee: bigint;
gasLimit: bigint;
}
function quoteUserTransaction(header: BlockHeader, prefs: UserPreferences) {
// Cap our max-fee at 2x base + priority so we don't get gouged on
// sudden base-fee spikes
const maxFee = calculateMaxFeePerGas(header.baseFeePerGas, prefs.priorityFee);
const effective = calculateEffectiveGasPrice(
header.baseFeePerGas,
maxFee,
prefs.priorityFee
);
const estimate = estimateGas({
gasLimit: prefs.gasLimit,
baseFeePerGas: header.baseFeePerGas,
maxPriorityFeePerGas: prefs.priorityFee,
});
return {
...estimate,
capMaxFee: maxFee,
effectivePrice: effective,
humanCost: `${estimateTxCostEth(prefs.gasLimit, effective)} ETH`,
};
}
const quote = quoteUserTransaction(
{ baseFeePerGas: 50000000000n },
{ priorityFee: 2000000000n, gasLimit: 21000n }
);
// { ..., humanCost: "0.000021 * effectivePrice / 10^18 ETH" }
```
## API Reference
| Export | Signature | Returns |
|---|---|---|
| `estimateGas` | `(params: GasEstimateParams) => GasEstimate` | full estimate |
| `calculateMaxFeePerGas` | `(baseFee: bigint, priorityFee: bigint) => bigint` | `2 * base + priority` |
| `calculateEffectiveGasPrice` | `(baseFee: bigint, maxFee: bigint, priorityFee: bigint) => bigint` | `min(maxFee, baseFee + priorityFee)` |
| `formatGwei` | `(weiValue: bigint) => string` | decimal gwei, signed, trimmed |
| `estimateTxCostEth` | `(gasLimit: bigint, gasPriceWei: bigint) => string` | decimal ETH, trimmed |
### `GasEstimate` shape
```ts
interface GasEstimate {
gasLimit: bigint;
gasPrice: bigint; // legacy override or maxFeePerGas
maxFeePerGas: bigint; // baseFee + priorityFee
maxPriorityFeePerGas: bigint;
totalCost: bigint; // gasLimit * gasPrice
}
```
## Implementation Notes
- `estimateGas`'s `maxFeePerGas` is computed as `baseFee + priorityFee` rather than the EIP-1559 ceiling (`2 * base + priority`) — the ceiling is computed separately via `calculateMaxFeePerGas` because some workflows want the conservative target (`base + priority`) while others want the worst-case ceiling. Callers wanting the ceiling can pass it via `legacyGasPrice` to override `gasPrice`.
- `formatGwei` divides by `GWEI = 10^9n` and uses `padStart(9, "0")` to render the fractional part. Trailing zeros are trimmed with `/0+$/` so `1100000000n` becomes `"1.1"`, not `"1.100000000"`.
- `estimateTxCostEth` divides by `10^18n` and uses `padStart(18, "0")` for the fractional ETH part. The same trailing-zero trim applies.
- Negative wei values in `formatGwei` are supported — the absolute value drives the math and the sign is reapplied at the end.
- `estimateTxCostEth` does not currently support negative inputs (gas refunds are out of scope for this primitive); the upstream library's behaviour is preserved bit-for-bit.
- EIP-4844 blob-gas helpers are **not** included — `calculateBlobGasPrice` and friends land in `add-cryptocurrency-blob-gas` as called out in the enterprise audit (`enterprise/cryptocurrency.md` §3).
- All arithmetic is `bigint`-pure; `formatGwei` and `estimateTxCostEth` are the only places where strings are produced, and even those use `BigInt.prototype.toString` exclusively.

View File

@@ -0,0 +1,64 @@
# token-data
## Purpose
Provides the `COMMON_TOKENS` constant: a curated list of eight well-known ERC-20 tokens on Ethereum mainnet — USDT, USDC, DAI, WBTC, WETH, LINK, UNI, MATIC. The list is used as the default seed for `TokenRegistry` when constructed with `preload = true` (the default). Consumers that want a richer registry can construct `new TokenRegistry(false)` and call `addToken` themselves, or build on top of `COMMON_TOKENS` by spreading the array.
This module exists as a static export so consumers can dead-code-eliminate it when they don't need a token registry; the entries here are byte-identical to upstream so any consumer relying on the addresses or decimals keeps working.
## Features
- Eight well-known mainnet ERC-20s: USDT, USDC, DAI, WBTC, WETH, LINK, UNI, MATIC.
- Every entry carries `address`, `symbol`, `decimals`, `chainId` (always `"ethereum"`), and `name`.
- Stablecoins use 6 decimals (USDT, USDC), WBTC uses 8, the rest use 18.
- Pure data export — no runtime initialisation, no side effects.
- Mutable array — consumers can spread it to build their own seeded registries.
## Usage: Basic
```ts
import { COMMON_TOKENS } from "@suaveplan/cryptocurrency";
const usdt = COMMON_TOKENS.find((t) => t.symbol === "USDT");
// { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", symbol: "USDT", decimals: 6, ... }
```
## Usage: Advanced
```ts
import {
COMMON_TOKENS,
type TokenInfo,
TokenRegistry,
} from "@suaveplan/cryptocurrency";
const myTokens: TokenInfo[] = [
...COMMON_TOKENS,
{
address: "0x111111111117dC0aa78b770fA6A738034120C302",
symbol: "1INCH",
decimals: 18,
chainId: "ethereum",
name: "1inch",
},
];
const registry = new TokenRegistry(false);
for (const token of myTokens) registry.addToken(token);
const oneInch = registry.getToken("ethereum", "0x111111111117dC0aa78b770fA6A738034120C302");
```
## API Reference
| Export | Type | Notes |
|---|---|---|
| `COMMON_TOKENS` | `TokenInfo[]` | 8 entries; mainnet ERC-20s only |
## Implementation Notes
- The array is `const`-bound but **not** `as const` — entries are mutable so callers can spread-merge their own overrides without TypeScript widening complaints.
- All addresses are EIP-55-checksummed (the canonical upstream representation) — `TokenRegistry.getToken` normalises lookups to lowercase, so consumers can pass any casing.
- Adding a new token to `COMMON_TOKENS` requires no other code changes — the token is automatically picked up by `TokenRegistry`'s default preload.
- The list is intentionally short. A more comprehensive registry (Uniswap token list, CoinGecko export) belongs in a downstream `@suaveplan/cryptocurrency-tokens` package; keeping the core curated avoids shipping a 200KB JSON blob in the universal package.
- All entries are on `ethereum` chainId; multi-chain entries (USDC on Polygon, USDT on BSC, etc.) are deliberately out of scope for the core list — they belong to chain-specific catalogues.

View File

@@ -0,0 +1,99 @@
# token-registry
## Purpose
Provides the `TokenRegistry` class for adding, retrieving, and searching ERC-20 / token information across chains, plus the `formatTokenBalance` helper for converting raw bigint balances into human-readable decimal strings. The registry is a thin `Map`-backed in-memory store keyed by `${chainId}:${address.toLowerCase()}`, so lookups are case-insensitive on the address — callers don't have to pre-normalise.
`TokenRegistry` is intentionally store-agnostic in its API but in-memory in its implementation. An RPC-backed `OnChainTokenRegistry` that dynamically fetches `decimals()` / `symbol()` from chain via an injected RPC client is called out in the enterprise audit (`enterprise/cryptocurrency.md` §4) but lands in a follow-up change.
## Features
- `addToken(token)` — upsert by chain + address.
- `getToken(chainId, address)` — case-insensitive lookup.
- `getTokensByChain(chainId)` — filtered list of all tokens on a chain.
- `searchBySymbol(query)` — case-insensitive substring match on `symbol`.
- `searchByName(query)` — case-insensitive substring match on `name`.
- `getAllTokens()` — snapshot array of every registered token.
- `size` accessor for fast count.
- Constructor accepts `preload = true` (default) to seed with `COMMON_TOKENS`; `false` starts empty.
- `formatTokenBalance(rawBalance, decimals)` — bigint → decimal string with sign + trailing-zero trim.
## Usage: Basic
```ts
import { formatTokenBalance, TokenRegistry } from "@suaveplan/cryptocurrency";
const registry = new TokenRegistry();
const usdt = registry.getToken(
"ethereum",
"0xdAC17F958D2ee523a2206206994597C13D831ec7"
);
const balance = formatTokenBalance(1500000n, usdt?.decimals ?? 6);
// "1.5"
```
## Usage: Advanced
```ts
import {
formatTokenBalance,
type TokenInfo,
TokenRegistry,
} from "@suaveplan/cryptocurrency";
class PortfolioView {
private readonly registry = new TokenRegistry();
public addCustomTokens(tokens: TokenInfo[]) {
for (const t of tokens) this.registry.addToken(t);
}
public formatHolding(
chainId: TokenInfo["chainId"],
address: string,
rawBalance: bigint
): string {
const token = this.registry.getToken(chainId, address);
if (!token) return `${rawBalance} (unknown token)`;
const balance = formatTokenBalance(rawBalance, token.decimals);
return `${balance} ${token.symbol}`;
}
public stablecoinHoldings(chainId: TokenInfo["chainId"]) {
return this.registry
.getTokensByChain(chainId)
.filter((t) => ["USDT", "USDC", "DAI"].includes(t.symbol));
}
}
```
## API Reference
### `TokenRegistry`
| Method | Signature | Notes |
|---|---|---|
| constructor | `(preload?: boolean = true)` | `false` starts empty |
| `addToken` | `(token: TokenInfo) => void` | upsert by `${chainId}:${address.toLowerCase()}` |
| `getToken` | `(chainId: ChainId, address: string) => TokenInfo | undefined` | case-insensitive address |
| `getTokensByChain` | `(chainId: ChainId) => TokenInfo[]` | filtered snapshot |
| `searchBySymbol` | `(query: string) => TokenInfo[]` | case-insensitive substring |
| `searchByName` | `(query: string) => TokenInfo[]` | case-insensitive substring |
| `getAllTokens` | `() => TokenInfo[]` | snapshot of every entry |
| `size` | accessor `number` | fast count |
### `formatTokenBalance`
| Signature | Returns |
|---|---|
| `(rawBalance: bigint, decimals: number) => string` | decimal string, signed, trimmed |
## Implementation Notes
- The internal `Map` key includes both `chainId` and `address.toLowerCase()` so multi-chain tokens with the same address (USDC on ethereum + USDC on polygon — wait, these have different addresses, but the pattern matters for sidechains) don't collide. The lowercase normalisation means callers never need to pre-checksum.
- `addToken` is an upsert — adding a token with an `(chainId, address)` pair that already exists replaces the entry. This is the contract the test suite pins.
- `searchBySymbol` matches against the uppercased symbol; `searchByName` matches against the lowercased name. Both are substring matches via `String.prototype.includes`, not regex, so the queries are literal — callers cannot pass regex metacharacters and expect them to be honoured.
- `formatTokenBalance` short-circuits to `rawBalance.toString()` when `decimals === 0` (no decimal point needed). For non-zero decimals it pads the fractional part with leading zeros, trims trailing zeros, and re-applies the sign. The `|| "0"` fallback handles the case where the fractional part is entirely zero after trimming.
- `formatTokenBalance` mirrors `formatGwei` and `estimateTxCostEth` in `gas-estimator.ts` — same bigint-only arithmetic, same trim algorithm. The duplication is intentional; refactoring to a shared helper would obscure the per-call-site decimal constant.
- Token-data updates (adding `LINK`, removing `MATIC`) are isolated to `token-data.ts`; this module never references the constants directly.
- An on-chain RPC-backed registry is scoped out per the proposal — it lands in `add-cryptocurrency-on-chain-tokens` once an `RpcClient` abstraction lands in `@suaveplan/types`.

View File

@@ -0,0 +1,108 @@
# tx-decoder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` classifies a transaction hash into one of `"evm" | "utxo" | "solana" | "unknown"` and returns the set of chains the hash could plausibly belong to. `extractSelector`, `lookupSelector`, and `decodeCalldata` work together to identify a function call from raw calldata when the called function is in the curated `KNOWN_SELECTORS` table (eight well-known ERC-20 / ERC-721 functions).
This module is a deliberate MVP: it does not decode argument bytes (ABI-aware decoding is scoped out per `enterprise/cryptocurrency.md` §3) and does not consult an online selector database (`4byte.directory` / `openchain.xyz`). Both gaps land in the `add-cryptocurrency-abi` follow-up change.
## Features
- `decodeTxHash(hash)` — classifies EVM (0x + 64 hex), UTXO (64 hex), Solana (base58 87-88 char) hashes and returns `TxMetadata { hash, chains, valid, hashType, hashHex? }`.
- `extractSelector(calldata)` — extracts the first 10 characters of the trimmed lowercased calldata when it looks like a function call; returns `undefined` otherwise.
- `lookupSelector(selector)` — case-insensitive lookup against the `KNOWN_SELECTORS` table.
- `decodeCalldata(calldata)` — composition of `extractSelector` then `lookupSelector`.
- `KNOWN_SELECTORS` — curated list of 8 well-known ERC-20 / ERC-721 function signatures.
- All inputs are trimmed; all matching is case-insensitive on hex.
## Usage: Basic
```ts
import {
decodeCalldata,
decodeTxHash,
extractSelector,
} from "@suaveplan/cryptocurrency";
const tx = decodeTxHash(`0x${"a".repeat(64)}`);
// { hash: "0xaa...", chains: ["ethereum", ...], valid: true, hashType: "evm", hashHex: "aa..." }
const sel = extractSelector("0xa9059cbb000000000000000000000000...");
// "0xa9059cbb"
const fn = decodeCalldata("0xa9059cbb000000000000000000000000...");
// { selector: "0xa9059cbb", name: "transfer", signature: "transfer(address,uint256)" }
```
## Usage: Advanced
```ts
import {
decodeCalldata,
type FunctionSignature,
KNOWN_SELECTORS,
lookupSelector,
} from "@suaveplan/cryptocurrency";
// Extend the curated table with project-specific signatures via in-memory composition
function makeSelectorResolver(extras: FunctionSignature[]) {
const merged = new Map<string, FunctionSignature>();
for (const s of [...KNOWN_SELECTORS, ...extras]) {
merged.set(s.selector.toLowerCase(), s);
}
return (selectorOrCalldata: string): FunctionSignature \| undefined => {
const baseline = decodeCalldata(selectorOrCalldata) ??
lookupSelector(selectorOrCalldata);
if (baseline) return baseline;
// Try the merged table as fallback for the project-specific extras
const normalised = selectorOrCalldata.toLowerCase().slice(0, 10);
return merged.get(normalised);
};
}
const resolve = makeSelectorResolver([
{
selector: "0xdeadbeef",
name: "myCustom",
signature: "myCustom(bytes32,address)",
},
]);
```
This pattern keeps the package's curated `KNOWN_SELECTORS` immutable while letting projects layer their own signatures on top — no need for a mutable global registry inside the package.
## API Reference
| Export | Signature | Notes |
|---|---|---|
| `KNOWN_SELECTORS` | `FunctionSignature[]` | 8 well-known selectors (transfer, approve, transferFrom, balanceOf, allowance, totalSupply, safeTransferFrom, setApprovalForAll) |
| `decodeTxHash` | `(hash: string) => TxMetadata` | classifies EVM / UTXO / Solana hashes |
| `lookupSelector` | `(selector: string) => FunctionSignature \| undefined` | case-insensitive on selector |
| `extractSelector` | `(calldata: string) => string \| undefined` | first 10 chars of `0x` + ≥ 8 hex chars |
| `decodeCalldata` | `(calldata: string) => FunctionSignature \| undefined` | composition of extract + lookup |
### `TxMetadata` shape
```ts
interface TxMetadata {
hash: string;
chains: ChainId[];
valid: boolean;
hashType: "evm" | "utxo" | "solana" | "unknown";
hashHex?: string; // populated for evm / utxo paths
}
```
## Implementation Notes
- The hash matchers are anchored regexes (`^...$`) to prevent partial matches. EVM (`0x` + 64 hex) is checked first because it would also match the UTXO pattern if the `0x` prefix were optional. The Solana base58 pattern excludes ambiguous characters (`0`, `O`, `I`, `l`) so it cannot collide with the hex patterns.
- `hashHex` is omitted for the Solana path (base58 is not hex). For the EVM path, the prefix is stripped; for the UTXO path, the whole input is lowercased.
- `extractSelector` requires `0x` + at least 8 hex characters — calldata containing only a selector (`"0xa9059cbb"`, 10 chars) qualifies; anything shorter is rejected. The slice always takes 10 chars (`"0x"` + 8 hex), even if more are present.
- `KNOWN_SELECTORS` is a mutable array (not `as const`) so consumers can spread it into custom collections. Sharing the array reference with callers that mutate it would corrupt the package's behaviour, but that is a misuse not worth defending against — `Object.freeze` would lose the spread-friendly mutability without preventing reassignment of indexed entries' fields anyway.
- The curated selector list is deliberately small. A comprehensive registry (4byte.directory export, openchain.xyz dump) belongs in a downstream `@suaveplan/cryptocurrency-selectors` package; keeping the core curated avoids shipping a ~50MB JSON blob.
- ABI-aware argument decoding (dynamic types, tuples, nested arrays) is **not** in this module. It lands in `add-cryptocurrency-abi` along with the pluggable online resolver interface — see `enterprise/cryptocurrency.md` §3.
- The `hashType: "unknown"` branch returns `chains: []` and `valid: false` — callers should dispatch on `valid` first, then on `hashType` for the success cases.

View File

@@ -0,0 +1,95 @@
# crypto.types
## Purpose
This module is the type-only foundation of `@suaveplan/cryptocurrency`. It declares every cross-cutting type the package exposes — the `ChainId` union of supported networks, the `ChainInfo` metadata record, the `ChainRegistryAdapter` async contract, validation result shapes for addresses and transaction hashes, gas-estimation inputs and outputs, token-registry records, transaction metadata, and the `FunctionSignature` calldata-decoder record. Importing from `@suaveplan/cryptocurrency/types/crypto.types` keeps consumers' bundles free of runtime code when they only need typings.
The types here drive the entire public surface of the package: every helper, every class, every error path references one or more of them. Keeping them in a single, dependency-free module guarantees there is one canonical home for the contract — no duplicate `ChainInfo` shapes drifting between modules, no fork between the validators and the registry.
## Features
- `ChainId` union covering the 15 mainnet networks the package validates against.
- `AddressType` discriminant (`"base58" | "bech32" | "hex" | "bech32m" | "ss58"`).
- `ChainInfo` metadata record with optional `evmChainId` distinguishing EVM from non-EVM chains.
- `ChainRegistryAdapter` async contract for pluggable chain storage.
- Validation result shapes (`AddressValidation`, `TxHashValidation`) returning structured `valid` / `chains` / `error` data rather than throwing.
- Gas-estimation contract (`GasEstimate`, `GasEstimateParams`) with `bigint` throughout to avoid number-precision pitfalls.
- Token-registry record (`TokenInfo`) and transaction metadata (`TxMetadata`) with `hashType` discriminant.
- `FunctionSignature` 4-byte selector record for the calldata decoder.
- Zero runtime code — pure `.d.ts` after build.
## Usage: Basic
```ts
import type { ChainId, ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
const id: ChainId = "ethereum";
const info: ChainInfo = {
id,
name: "Ethereum",
nativeSymbol: "ETH",
evmChainId: 1,
addressType: "hex",
explorerUrl: "https://etherscan.io",
decimals: 18,
testnet: false,
};
```
## Usage: Advanced
```ts
import type {
ChainInfo,
ChainRegistryAdapter,
} from "@suaveplan/cryptocurrency/types/crypto.types";
class DbBackedRegistry implements ChainRegistryAdapter {
public constructor(private readonly db: { query: (sql: string) => Promise<ChainInfo[]> }) {}
public async getChain(id: ChainInfo["id"]) {
const rows = await this.db.query(`select * from chains where id = '${id}'`);
return rows[0];
}
public async getAllChains() {
return this.db.query("select * from chains");
}
public async getChainByEvmId(evmChainId: number) {
const rows = await this.db.query(`select * from chains where evm_chain_id = ${evmChainId}`);
return rows[0];
}
public async registerChain(chain: ChainInfo) {
await this.db.query(`insert into chains values (${JSON.stringify(chain)})`);
}
}
```
The adapter contract is async by design so callers can plug in IndexedDB, SurrealDB, or HTTP-backed implementations without changing the consumer-facing API.
## API Reference
| Export | Kind | Notes |
|---|---|---|
| `ChainId` | union type | 15 mainnet chain ids |
| `AddressType` | union type | discriminant on `ChainInfo.addressType` |
| `ChainInfo` | interface | chain metadata; `evmChainId` optional, `testnet` always `false` for built-ins |
| `CryptoUnit` | interface | unit definition (`name`, `decimals`, `symbol`) |
| `AddressValidation` | interface | `{ valid, normalized?, chains, error? }` |
| `TxHashValidation` | interface | `{ valid, chains, error? }` |
| `TokenInfo` | interface | ERC-20 record; `address` keys are normalised case-insensitively by `TokenRegistry` |
| `GasEstimate` | interface | bigint fields only |
| `GasEstimateParams` | interface | inputs to `estimateGas`; `legacyGasPrice` optional |
| `TxMetadata` | interface | `hashType: "evm" \| "utxo" \| "solana" \| "unknown"` |
| `FunctionSignature` | interface | calldata-selector record |
| `ChainRegistryAdapter` | interface | async chain-storage contract |
## Implementation Notes
- No inline object shapes in interface bodies — every nested type is a named declaration (CLAUDE.md rule §11).
- `AddressType` is a separate union so consumers can dispatch on it without re-declaring the variants.
- `ChainInfo.evmChainId` is optional rather than `number | null` to keep the JSON serialisation clean and match upstream behaviour.
- The validators return structured `AddressValidation` / `TxHashValidation` records rather than throwing — callers that prefer typed throws can wrap the result and raise `CryptocurrencyError({ code: CRYPTOCURRENCY.INVALID_ADDRESS | INVALID_HASH })` themselves.
- Every interface field is documented inline so the generated `.d.ts` carries JSDoc to consumers' IDEs.

View File

@@ -0,0 +1,97 @@
# converter (units)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates over the three built-in currency registries (ETH: wei / kwei / mwei / gwei / microether / milliether / ether; BTC: satoshi / microbitcoin / millibitcoin / bitcoin; SOL: lamport / sol) and uses `bigint` arithmetic end-to-end so callers never lose precision at the `Number` boundary. Convenience wrappers (`weiToEther`, `etherToWei`, `gweiToEther`, `satoshiToBitcoin`, `bitcoinToSatoshi`, `lamportToSol`) are thin specialisations for the common operations. `getUnits(currency)` returns the full registry for a currency so UIs can enumerate denominations.
Errors are typed: `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CURRENCY })` for currencies outside ETH/BTC/SOL, `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_UNIT })` for unrecognised denominations.
## Features
- Generic `convertUnits(value, fromUnit, toUnit, currency)` across ETH / BTC / SOL.
- Six convenience wrappers for common conversions.
- `getUnits(currency)` for enumerating registered denominations.
- Bigint-pure arithmetic — no `Number` boundary on the hot path.
- Case-insensitive currency tags.
- Typed `CryptocurrencyError` with `currency` / `unit` discriminants for programmatic dispatch.
- Universal — no `node:*` / `bun:*` / `crypto` imports.
## Usage: Basic
```ts
import {
convertUnits,
etherToWei,
satoshiToBitcoin,
} from "@suaveplan/cryptocurrency";
convertUnits("1", "ether", "wei", "ETH"); // "1000000000000000000"
etherToWei("1"); // "1000000000000000000"
satoshiToBitcoin("100000000"); // "1"
```
## Usage: Advanced
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
convertUnits,
getUnits,
} from "@suaveplan/cryptocurrency";
function safeConvert(
value: string,
fromUnit: string,
toUnit: string,
currency: string
): { ok: true; value: string } | { ok: false; reason: string } {
try {
return { ok: true, value: convertUnits(value, fromUnit, toUnit, currency) };
} catch (err) {
if (err instanceof CryptocurrencyError) {
switch (err.code) {
case CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY:
return { ok: false, reason: `unsupported currency: ${err.currency}` };
case CRYPTOCURRENCY_CODES.UNKNOWN_UNIT:
return { ok: false, reason: `unsupported unit '${err.unit}' for ${err.currency}` };
}
}
throw err;
}
}
const supportedEth = getUnits("ETH");
// [{ name: "wei", decimals: 0, symbol: "wei" }, ..., { name: "ether", decimals: 18, symbol: "ETH" }]
```
## API Reference
| Export | Signature | Notes |
|---|---|---|
| `convertUnits` | `(value: string \| bigint, fromUnit: string, toUnit: string, currency: string) => string` | throws typed `CryptocurrencyError` for unknown currency / unit |
| `weiToEther` | `(wei: string \| bigint) => string` | convenience wrapper |
| `etherToWei` | `(ether: string \| bigint) => string` | convenience wrapper |
| `gweiToEther` | `(gwei: string \| bigint) => string` | convenience wrapper |
| `satoshiToBitcoin` | `(satoshi: string \| bigint) => string` | convenience wrapper |
| `bitcoinToSatoshi` | `(btc: string \| bigint) => string` | convenience wrapper |
| `lamportToSol` | `(lamport: string \| bigint) => string` | convenience wrapper |
| `getUnits` | `(currency: string) => CryptoUnit[]` | returns `[]` for unknown currencies (no throw) |
### Error contract
- `CryptocurrencyError({ code: "CRYPTOCURRENCY.UNKNOWN_CURRENCY", currency })` — currency not in ETH / BTC / SOL (case-insensitive comparison after `toUpperCase`).
- `CryptocurrencyError({ code: "CRYPTOCURRENCY.UNKNOWN_UNIT", currency, unit })` — unit not in the currency's registry.
## Implementation Notes
- The `parseToBigInt` helper takes the integer part of a decimal string input via `String.prototype.split(".")[0]`. Fractional sub-units are not representable in `bigint`, and the converter's input semantics are unit-relative ("the value is already in `fromUnit`"), so dropping the fractional part matches the upstream contract.
- The conversion math distinguishes three cases: same decimals (no-op), coarser-to-finer (multiply by `10^delta`), finer-to-coarser (divide via `formatBigIntWithDecimals`). The third case is the only one that produces a fractional result string.
- `formatBigIntWithDecimals` mirrors the same trim-trailing-zeros algorithm used by `formatGwei`, `estimateTxCostEth`, and `formatTokenBalance`. The duplication is intentional — extracting a shared helper would couple modules that have no other relationship.
- `convertUnits` uppercases the currency tag before lookup, so `"btc"`, `"BTC"`, `"Btc"` all resolve to the same registry. The `currency` discriminant on the thrown error uses the **original** caller-supplied string for `UNKNOWN_CURRENCY` (since the lookup hadn't succeeded yet) and the **normalised** uppercase form for `UNKNOWN_UNIT` (since by then the currency is known good).
- `getUnits` returns `[]` rather than throwing for unknown currencies — this matches the upstream contract and lets UIs render "no denominations" without try-catch ceremony.
- The Greek `µ` symbol in the `microether` / `microbitcoin` definitions is the actual Unicode `MICRO SIGN` (U+00B5) — the file's UTF-8 encoding preserves it. Bundlers that don't handle UTF-8 source will mangle the symbol, but every modern toolchain handles it correctly.

View File

@@ -0,0 +1,84 @@
# @suaveplan/currency-provider-binance - Universal Binance public-ticker crypto-rates provider for @suaveplan/currency. Sibling provider package: implements the CurrencyProvider contract from @suaveplan/currency on top of the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). No API key required.
Binance public-ticker crypto-rates provider for `@suaveplan/currency`.
Sibling package in the foundation-with-sibling-plugins shape: implements
the `CurrencyProvider` contract on top of the slim
`@suaveplan/currency-providers` core (`httpRequest`,
`CurrencyProviderError`, `NoKeyConfig`). Universal — runs unchanged
under Node 18+, Bun, modern browsers, and Web Workers. No API key
required.
<!-- begin:auto-docs -->
## Documentation tree
- [binance](./src/binance/binance.md) — `BinanceProvider` is the Binance sibling implementation of the `CurrencyProvider` contract from `@suaveplan/currency`.…
- [binance](./src/binance/binance.md) — `BinanceProvider` is the Binance sibling implementation of the `CurrencyProvider` contract from `@suaveplan/currency`.…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-binance @suaveplan/currency @suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { BinanceProvider } from "@suaveplan/currency-provider-binance";
const registry = new CurrencyProviderRegistry();
registry.register(new BinanceProvider());
const converter = new CurrencyConverter({
provider: registry.get("binance"),
staleDurationMs: 30_000,
});
const rate = await converter.getRate("USDT", "BTC");
```
Direct instantiation without the registry works exactly the same:
```ts
const converter = new CurrencyConverter({
provider: new BinanceProvider(),
});
```
## Features
- Public Binance spot ticker endpoint (`/api/v3/ticker/price`) — free,
no API key, no signature, no OAuth
- Default base `USDT`; any suffix-style base supported by Binance works
(`EUR`, `BTC`, `ETH`, etc.) by passing `base` to `fetchRates`
- Optional `targets` filter raises typed `UNKNOWN_CURRENCY` for any
target absent from the upstream listing — surfaces missing pairs
eagerly rather than silently dropping them
- Sanitised pricing: zero, negative, and non-finite ticker prices are
skipped (delisted-but-published symbols stay out of the cache)
- Rate inversion: every rate stored as `1 / price` so the cache answers
"1 unit of base = N units of crypto"
- Injectable `FetchLike` for tests, proxies, and edge-runtime adapters;
optional `timeoutMs` flows through to the shared `httpRequest` helper
- Typed errors via `CurrencyProviderError` carrying one of `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY` — no plain
`Error` ever escapes
- Universal; explicit named barrel; one subpath export beyond the
root; 100/100/100/100 istanbul coverage
## Modules
- [`src/binance/binance.md`](./src/binance/binance.md) — `BinanceProvider`
class, `BINANCE_PROVIDER_NAME`, `BINANCE_DEFAULT_BASE_URL`
## Status
`0.1.0` (pre-graduation). Follow-up to the 2026-05-17 slim refactor of
`@suaveplan/currency-providers` — see
the design proposal
for the per-package change record and
the design document for the
cross-cutting sibling-shape architecture.

View File

@@ -0,0 +1,159 @@
# BinanceProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BinanceProvider` is the Binance sibling implementation of the
`CurrencyProvider` contract from `@suaveplan/currency`. It fetches the
public Binance spot ticker (`GET /api/v3/ticker/price`) — a free,
key-less endpoint that returns the latest mid-price for every listed
trading pair — and decodes it into a `RateFetchResult` ready for the
`CurrencyConverter`'s cache. The provider is universal with a
shape that mirrors every other `@suaveplan/currency-provider-<name>`
sibling: a `NoKeyConfig`-shaped constructor with optional `baseUrl`
override and optional injectable `FetchLike`, a `fetchRates` method that
delegates HTTP transport to the slim core's `httpRequest` helper, and
typed `CurrencyProviderError`s for every failure mode (no plain
`Error`).
## Features
- Public Binance ticker endpoint — no API key, no OAuth, no signature
- Default base `USDT`; any other suffix-style base supported by Binance
(e.g. `EUR`, `BTC`, `ETH`) works by passing `base` to `fetchRates`
- Optional `targets` filter raises `UNKNOWN_CURRENCY` for any target the
upstream does not list against the requested base — surfaces missing
pairs eagerly rather than silently dropping them
- Suffix matching: a ticker `BTCUSDT` becomes `BTC` when base is `USDT`;
symbols equal to the suffix (`USDTUSDT` quirks) are defensively skipped
- Sanitised pricing: zero, negative, and non-finite prices are skipped
- Rate inversion: every rate is stored as `1 / price` so the cache
answers "1 unit of base = N units of crypto"
- Injectable `FetchLike` for tests, proxies, and edge-runtime adapters
- Optional `timeoutMs` flows through to `httpRequest` and derives an
`AbortSignal.timeout(ms)` composed with the caller's `signal`
- Typed errors via `CurrencyProviderError` + the 8-code
`CURRENCY_PROVIDER_*` registry — `HTTP_ERROR` (non-2xx), `PARSE_ERROR`
(non-JSON body), `UPSTREAM_ERROR` (unexpected shape),
`UNKNOWN_CURRENCY` (target absent from upstream listing)
## Usage: Basic
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { BinanceProvider } from "@suaveplan/currency-provider-binance";
const provider = new BinanceProvider();
const converter = new CurrencyConverter({
provider,
staleDurationMs: 30_000,
});
const rate = await converter.getRate("USDT", "BTC");
console.log(rate);
```
## Usage: Advanced
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import {
BinanceProvider,
BINANCE_PROVIDER_NAME,
} from "@suaveplan/currency-provider-binance";
import type { FetchLike } from "@suaveplan/types";
// Wire a constrained FetchLike (corporate proxy, retry harness, etc.).
const proxiedFetch: FetchLike = async (url, init) => {
return await fetch(`https://proxy.example.com/${String(url)}`, init);
};
const binance = new BinanceProvider({
fetch: proxiedFetch,
baseUrl: "https://api.binance.com/api/v3",
timeoutMs: 8_000,
});
const registry = new CurrencyProviderRegistry();
registry.register(binance);
// Fetch only the pairs we care about — UNKNOWN_CURRENCY surfaces any
// upstream listing changes immediately.
const result = await registry
.get(BINANCE_PROVIDER_NAME)
.fetchRates({ base: "USDT", targets: ["BTC", "ETH", "SOL"] });
console.log(result.rates);
```
## API Reference
### `BINANCE_PROVIDER_NAME: "binance"`
Stable provider identifier — surfaced through `provider.name` and used
as the registry key.
### `BINANCE_DEFAULT_BASE_URL: "https://api.binance.com/api/v3"`
Default REST base URL. Overridable via the constructor `baseUrl` for
staging or proxy deployments.
### `class BinanceProvider implements CurrencyProvider`
Constructor: `new BinanceProvider(config?: NoKeyConfig & { timeoutMs?: number })`.
- `config.baseUrl` — overrides `BINANCE_DEFAULT_BASE_URL`
- `config.fetch` — optional injectable `FetchLike`; defaults to
`globalThis.fetch`
- `config.timeoutMs` — optional per-request timeout in milliseconds; when
set, derives an `AbortSignal.timeout` composed with the caller's
`signal`
Properties:
- `name === "binance"` (literal)
- `supportsCrypto === true`
- `supportsFiat === false`
Method: `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`.
- `options.base` — base currency suffix (default `"USDT"`)
- `options.targets` — optional list of target currencies; raises
`UNKNOWN_CURRENCY` for any target absent from the upstream listing
- `options.signal` — optional `AbortSignal` forwarded to `httpRequest`
Throws `CurrencyProviderError` with one of:
- `CURRENCY_PROVIDER.HTTP_ERROR` — non-2xx response from Binance
- `CURRENCY_PROVIDER.PARSE_ERROR` — body is not valid JSON
- `CURRENCY_PROVIDER.UPSTREAM_ERROR` — payload is not the expected
`Array<{ symbol: string; price: string }>` shape
- `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` — a requested target is not
listed against the requested base
## Implementation Notes
- Suffix matching: `endsWith(base)` plus a length check rejects symbols
whose entire name equals the suffix (`USDTUSDT`-style edge cases),
which would otherwise decode to an empty `""` rate key
- Pricing: `Number.parseFloat` plus `Number.isFinite && price > 0` is
the canonical "is this a usable mid-price" filter — zero, negative,
and `NaN` values are silently skipped (not surfaced as errors) because
Binance occasionally returns those for delisted-but-still-published
symbols
- Rate inversion: storing `1 / price` keeps the `RateFetchResult.rates`
map answering "1 unit of base = N units of asset", matching the
convention used by the rest of `@suaveplan/currency`
- Transport: HTTP is delegated entirely to the slim core's
`httpRequest` so every sibling provider raises identical `HTTP_ERROR`
payloads, supports the same abort/timeout composition, and benefits
from any future improvements (retry, circuit breaker) made there
- JSON parsing: `response.json()` is wrapped so non-JSON bodies become
`PARSE_ERROR` rather than leaking a runtime `SyntaxError`; payload
shape validation is a typed structural check (no runtime Zod cost in
the universal core)
- IP-based rate limiting still applies upstream; the provider is
intentionally rate-limit-agnostic — wrap it in the future
`RateLimitedProvider` when that ships

View File

@@ -0,0 +1,79 @@
# @suaveplan/currency-provider-coingecko - Universal CoinGecko sibling for @suaveplan/currency-providers. Exposes CoinGeckoProvider (free public tier + optional Pro-tier x-cg-pro-api-key) and the getCoinGeckoId symbol lookup; routes every request through the shared httpRequest helper and raises typed CurrencyProviderError on HTTP_ERROR / PARSE_ERROR / UPSTREAM_ERROR / UNKNOWN_CURRENCY.
Universal CoinGecko sibling for
[`@suaveplan/currency-providers`](../currency-providers/README.md). Implements
the `CurrencyProvider` contract from `@suaveplan/currency` against the
public `/simple/price` endpoint, with optional Pro-tier `apiKey` support
attached as `x-cg-pro-api-key` on every outbound request. Both free and
Pro tiers share the same URL shape, so upgrading is a single-field
config change.
<!-- begin:auto-docs -->
## Documentation tree
- [coin-ids](./src/coin-ids/coin-ids.md) — Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to the opaque CoinGecko coin id (`bitcoin`, `et…
- [coin-ids](./src/coin-ids/coin-ids.md) — Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to the opaque CoinGecko coin id (`bitcoin`, `et…
- [coingecko](./src/coingecko/coingecko.md) — CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/curr…
- [coingecko](./src/coingecko/coingecko.md) — CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/curr…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-coingecko
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
// Free public tier — no API key required.
const free = new CoinGeckoProvider();
// Pro tier — pass apiKey; the same provider attaches x-cg-pro-api-key.
const pro = new CoinGeckoProvider({
apiKey: process.env.COINGECKO_PRO_KEY,
baseUrl: "https://pro-api.coingecko.com/api/v3",
});
const registry = new CurrencyProviderRegistry();
registry.register(pro);
const converter = new CurrencyConverter({
provider: registry.get("coingecko"),
staleDurationMs: 60_000,
});
const rates = await registry
.get("coingecko")
.fetchRates({ base: "USD", targets: ["BTC", "ETH"] });
```
## Features
- `CoinGeckoProvider implements CurrencyProvider` — drops directly into
any `@suaveplan/currency` converter or `CurrencyProviderRegistry`.
- Truly optional `apiKey` — free tier requires no config; Pro tier
attaches `x-cg-pro-api-key` automatically.
- 16 curated cryptocurrencies (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA,
DOGE, DOT, AVAX, MATIC, LINK, UNI, ATOM, LTC).
- Routes every request through the shared `httpRequest()` helper from
`@suaveplan/currency-providers` for abort-signal composition,
per-call timeouts, and typed HTTP failures.
- Proactive `UNKNOWN_CURRENCY` gate — unknown `options.targets` reject
before the HTTP call is issued.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`.
- Universal — zero browser / Node-only APIs. Works in Node 18+, Bun,
modern browsers, and Web Workers.
## Modules
- [`src/coingecko/coingecko.md`](./src/coingecko/coingecko.md) —
`CoinGeckoProvider` class + `CoinGeckoConfig` type.
- [`src/coin-ids/coin-ids.md`](./src/coin-ids/coin-ids.md) — `COIN_IDS`
curated map + `getCoinGeckoId(symbol)` lookup.

View File

@@ -0,0 +1,88 @@
# coin-ids
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to
the opaque CoinGecko coin id (`bitcoin`, `ethereum`, …) that the
`/simple/price` endpoint requires. Without this translation step the
provider would have to either fall back to CoinGecko's heavyweight
`/coins/list` discovery endpoint (~10k entries, defeats tree-shaking) or
ask every caller to learn CoinGecko's id convention — neither is
acceptable for a slim sibling.
## Features
- Frozen `COIN_IDS` map covering the top 16 cryptos by market cap at
landing (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA, DOGE, DOT, AVAX,
MATIC, LINK, UNI, ATOM, LTC).
- Case-insensitive `getCoinGeckoId(symbol)` lookup returning `string | undefined`.
- Zero runtime dependencies; pure data + a one-line lookup function.
## Usage: Basic
```ts
import { getCoinGeckoId } from "@suaveplan/currency-provider-coingecko/coin-ids/coin-ids";
getCoinGeckoId("BTC"); // "bitcoin"
getCoinGeckoId("eth"); // "ethereum" — case-insensitive
getCoinGeckoId("ZZZ"); // undefined — unknown symbol
```
## Usage: Advanced
The provider uses `getCoinGeckoId` as the proactive `UNKNOWN_CURRENCY`
gate. Consumers can mirror the same pattern when composing their own
filters before hitting the registry:
```ts
import {
COIN_IDS,
getCoinGeckoId,
} from "@suaveplan/currency-provider-coingecko/coin-ids/coin-ids";
function partitionSupported(symbols: readonly string[]): {
supported: string[];
unknown: string[];
} {
const supported: string[] = [];
const unknown: string[] = [];
for (const symbol of symbols) {
if (getCoinGeckoId(symbol) !== undefined) {
supported.push(symbol);
} else {
unknown.push(symbol);
}
}
return { supported, unknown };
}
// Iterate the full map when generating a UI dropdown:
const everyKnown = Object.keys(COIN_IDS);
```
## API Reference
### `COIN_IDS: Readonly<Record<string, string>>`
Frozen mapping from uppercase ticker (key) to CoinGecko coin id (value).
Keys are always uppercase; mutation throws because the object is frozen.
### `getCoinGeckoId(symbol: string): string | undefined`
Returns the CoinGecko coin id for the given symbol. The input is
upper-cased before the lookup, so `"btc"` and `"BTC"` both resolve to
`"bitcoin"`. Returns `undefined` for any symbol not present in
`COIN_IDS`. Does not throw.
## Implementation Notes
The map is intentionally hand-curated rather than fetched at runtime: a
sibling that issues a discovery call before every batch would (a) add a
network round trip every consumer pays for once, (b) bloat the
in-memory cache, and (c) couple this package's reliability to a second
upstream endpoint whose response shape can drift. Adding a new symbol is
a one-line PR; the trade-off is heavily skewed toward "type it in".

View File

@@ -0,0 +1,114 @@
# CoinGeckoProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
CoinGecko sibling for `@suaveplan/currency-providers`. Implements the
`CurrencyProvider` contract from `@suaveplan/currency` against the
public `/simple/price` endpoint, with optional Pro-tier `apiKey`
support that attaches the `x-cg-pro-api-key` header to every outbound
request. The free public tier (no key, 10–30 calls/min) and the Pro
tier share the same URL shape, so upgrading from free to Pro is a
single-field config change with no other wiring impact.
## Features
- `CoinGeckoProvider implements CurrencyProvider` — `name = "coingecko"`,
`supportsCrypto = true`, `supportsFiat = true`.
- Optional `apiKey` config (`x-cg-pro-api-key` header attachment).
- Optional `baseUrl` and `fetch` config (default
`https://api.coingecko.com/api/v3` and `globalThis.fetch`).
- Caller `AbortSignal` propagation via the shared `httpRequest()` helper
(composes with timeouts from the helper).
- Proactive `UNKNOWN_CURRENCY` gate — unknown `options.targets` reject
before the HTTP call is issued.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`.
## Usage: Basic
```ts
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko/coingecko/coingecko";
const provider = new CoinGeckoProvider();
const result = await provider.fetchRates({ base: "USD" });
// result.rates.BTC = 0.0000149… (1 USD ≈ 0.0000149 BTC at ~$67k/BTC)
```
## Usage: Advanced
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko/coingecko/coingecko";
const registry = new CurrencyProviderRegistry();
registry.register(
new CoinGeckoProvider({
apiKey: process.env.COINGECKO_PRO_KEY,
baseUrl: "https://pro-api.coingecko.com/api/v3",
}),
);
const converter = new CurrencyConverter({
provider: registry.get("coingecko"),
staleDurationMs: 60_000, // 1 minute — crypto rates move fast
});
// Targets are validated against the curated COIN_IDS map BEFORE the
// HTTP call — passing an unknown target raises UNKNOWN_CURRENCY
// without making a network round trip.
const controller = new AbortController();
const rates = await registry.get("coingecko").fetchRates({
base: "EUR",
targets: ["BTC", "ETH"],
signal: controller.signal,
});
```
## API Reference
### `class CoinGeckoProvider implements CurrencyProvider`
- `constructor(config?: CoinGeckoConfig)` — config is fully optional.
- `readonly name: "coingecko"`
- `readonly supportsCrypto: true`
- `readonly supportsFiat: true`
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
- Issues `GET ${baseUrl}/simple/price?ids=<curated-ids>&vs_currencies=<base.toLowerCase()>`.
- Forwards `options.signal` to the shared `httpRequest()` helper.
- Pre-validates `options.targets` against `COIN_IDS`; an unknown entry
raises `CurrencyProviderError(UNKNOWN_CURRENCY)` before any network
activity.
- Inverts upstream "1 crypto = X fiat" prices into the converter's
"1 base = X crypto" convention.
- Throws `CurrencyProviderError` with `code` ∈ {`HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`} carrying
`providerName === "coingecko"`.
### `interface CoinGeckoConfig extends NoKeyConfig`
- `readonly baseUrl?: string` — overrides `https://api.coingecko.com/api/v3`.
- `readonly fetch?: FetchLike` — overrides `globalThis.fetch`.
- `readonly apiKey?: string` — Pro-tier key, attached as
`x-cg-pro-api-key` header when present.
## Implementation Notes
- Pre-validating `options.targets` against `COIN_IDS` (not against the
upstream response) means the rejection message names the bad symbol
rather than swallowing it with a missing-key in the result map. This
is the difference between "your `ZZZ` symbol is not in our curated
set" (actionable) and "your rate result has no `ZZZ` field" (silent
defect).
- The shape guard (`isResponseShape`) accepts any value matching
`Record<string, Record<string, number>>` rather than asserting the
exact `bitcoin`/`ethereum` keys CoinGecko currently returns. The
upstream may add new coins or rename old ones, and a strict assertion
would treat that as a wire-format break instead of a benign superset.
- Headers are constructed once at instantiation time and frozen so the
per-request `init` spread is the only mutable copy — defends against
accidental key leakage into a downstream debugger.

View File

@@ -0,0 +1,91 @@
# @suaveplan/currency-provider-coinmarketcap - CoinMarketCap crypto-rates provider for @suaveplan/currency — sibling of @suaveplan/currency-providers. Authenticates via X-CMC_PRO_API_KEY header against the /cryptocurrency/quotes/latest endpoint. Universal (no node:/bun:/DOM imports).
CoinMarketCap crypto-rates sibling provider for `@suaveplan/currency`. One
of the nine `@suaveplan/currency-provider-<name>` sibling packages spun
out of the 2026-05-17 slim refactor of `@suaveplan/currency-providers`.
Consumes the slim core's `CurrencyProviderError` registry, `ApiKeyConfig`
type, and shared `httpRequest()` HTTP helper; talks to
`pro-api.coinmarketcap.com` via the `X-CMC_PRO_API_KEY` header.
<!-- begin:auto-docs -->
## Documentation tree
- [coinmarketcap](./src/coinmarketcap/coinmarketcap.md) — `CoinMarketCapProvider` is the `@suaveplan/currency-providers` sibling that fetches cryptocurrency spot prices from the…
- [coinmarketcap](./src/coinmarketcap/coinmarketcap.md) — `CoinMarketCapProvider` is the `@suaveplan/currency-providers` sibling that fetches cryptocurrency spot prices from the…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-coinmarketcap @suaveplan/currency-providers @suaveplan/currency
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const registry = new CurrencyProviderRegistry();
registry.register(
new CoinMarketCapProvider({ apiKey: process.env.CMC_API_KEY ?? "" })
);
const converter = new CurrencyConverter({
provider: registry.get("coinmarketcap"),
});
const rate = await converter.getRate("BTC", "USD");
```
Direct instantiation works equally well:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const converter = new CurrencyConverter({
provider: new CoinMarketCapProvider({ apiKey: process.env.CMC_API_KEY ?? "" }),
});
```
## Features
- Implements `CurrencyProvider` (`name = "coinmarketcap"`,
`supportsCrypto = true`, `supportsFiat = false`)
- `ApiKeyConfig` constructor — `apiKey` required, `baseUrl` optional
(defaults to `https://pro-api.coinmarketcap.com/v1`), injectable
`FetchLike` from `@suaveplan/types`
- Authenticated via the upstream `X-CMC_PRO_API_KEY` header; `Accept`
set to `application/json` on every request
- Default target list covers the top 10 crypto by market cap
(`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`); override via
`options.targets`
- Default `base` is `USD`; any fiat conversion currency works via
`options.base`
- Prices are inverted (`1 / price`) into the converter's expected
`1 fiat = X crypto` form
- Typed errors via `CurrencyProviderError` —
`MISSING_CONFIG` (empty `apiKey`), `HTTP_ERROR` (non-2xx),
`PARSE_ERROR` (invalid JSON / missing data), `UPSTREAM_ERROR`
(`status.error_code` populated), `UNKNOWN_CURRENCY` (no symbol
resolved). Every error carries `providerName: "coinmarketcap"`
- HTTP plumbing (abort-signal composition, timeout, non-2xx mapping)
delegated to the slim core's `httpRequest()` — uniform with every
other sibling provider
- Universal (`sideEffects: false`); 100/100/100/100 istanbul
coverage; 2 subpath exports
## Modules
- [`src/coinmarketcap/coinmarketcap.md`](./src/coinmarketcap/coinmarketcap.md)
— `CoinMarketCapProvider`
## Status
`0.1.0` (pre-graduation). Sibling provider landed alongside the
2026-05-17 slim refactor of `@suaveplan/currency-providers`. See
the design proposal for
the change record.

View File

@@ -0,0 +1,160 @@
# `CoinMarketCapProvider` — crypto-rates sibling provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CoinMarketCapProvider` is the
`@suaveplan/currency-providers` sibling that fetches cryptocurrency
spot prices from the CoinMarketCap professional REST API
(`pro-api.coinmarketcap.com`). It implements `CurrencyProvider` from
`@suaveplan/currency` so it slots directly into `CurrencyConverter`
either by direct instantiation (`new CurrencyConverter({ provider: new
CoinMarketCapProvider(...) })`) or by registration with
`CurrencyProviderRegistry`. The class exists so consumers that need
CoinMarketCap as a primary or fallback rate source install exactly one
provider package instead of pulling in the eight other sibling
providers they do not wire in.
The class is crypto-only — `supportsCrypto = true`, `supportsFiat =
false` — because the upstream `/cryptocurrency/quotes/latest` endpoint
is a crypto quotes feed; the request still accepts any fiat conversion
currency via the `convert` query parameter, so a USD-base, EUR-base, or
GBP-base call is equally valid. Consumers that need fiat rates should
register a fiat sibling alongside (`currency-provider-ecb`,
`currency-provider-fixer`, `currency-provider-open-exchange-rates`).
## Features
- Implements `CurrencyProvider` with stable identity (`name =
"coinmarketcap"`, `supportsCrypto = true`, `supportsFiat = false`).
- Constructor accepts `ApiKeyConfig` (`apiKey`, optional `baseUrl`,
optional injectable `FetchLike`); empty / whitespace `apiKey`
immediately raises `CurrencyProviderError(MISSING_CONFIG)`.
- `X-CMC_PRO_API_KEY` + `Accept: application/json` headers carried on
every request through the shared `httpRequest()` helper, so abort
signals, timeouts, and HTTP error mapping are uniform with every
other sibling provider.
- Default target list covers the top 10 cryptocurrencies by market cap
(`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`); callers can override
via `options.targets` to query any supported symbol set.
- Default `base` is `USD`; any fiat conversion currency supported by
CoinMarketCap is accepted via `options.base`.
- Prices are inverted from CoinMarketCap's `1 crypto = X fiat` form to
the converter's expected `1 fiat = (1 / X) crypto` form before being
written into `RateFetchResult.rates`.
- Typed error mapping — `HTTP_ERROR` (non-2xx), `PARSE_ERROR` (invalid
JSON / missing `data` field / non-object data), `UPSTREAM_ERROR`
(`status.error_code` populated), `UNKNOWN_CURRENCY` (no requested
symbol resolved), `MISSING_CONFIG` (empty `apiKey`) — every error
carries `providerName: "coinmarketcap"`.
- Universal — no `node:` / `bun:` / DOM imports; runs unchanged
under Node 18+, Bun, modern browsers, and Web Workers via the
injectable `FetchLike` seam.
## Usage: Basic
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const provider = new CoinMarketCapProvider({
apiKey: process.env.CMC_API_KEY ?? "",
});
const converter = new CurrencyConverter({ provider });
const rate = await converter.getRate("BTC", "USD");
```
## Usage: Advanced
Inject a `FetchLike` for testing, point at the CoinMarketCap sandbox,
restrict the target list, and forward an `AbortSignal` for cancellation:
```ts
import type { FetchLike } from "@suaveplan/types";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const recordingFetch: FetchLike = async (url, init) => {
console.log("→", url);
return await fetch(url, init);
};
const provider = new CoinMarketCapProvider({
apiKey: process.env.CMC_SANDBOX_KEY ?? "",
baseUrl: "https://sandbox-api.coinmarketcap.com/v1",
fetch: recordingFetch,
});
const registry = new CurrencyProviderRegistry();
registry.register(provider);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
try {
const result = await registry.get("coinmarketcap").fetchRates({
base: "EUR",
targets: ["BTC", "ETH", "SOL"],
signal: controller.signal,
});
console.log(result.rates);
} finally {
clearTimeout(timer);
}
```
## API Reference
### `class CoinMarketCapProvider implements CurrencyProvider`
- `readonly name: "coinmarketcap"`
- `readonly supportsCrypto: true`
- `readonly supportsFiat: false`
- `constructor(config: ApiKeyConfig)` — `config.apiKey` is required;
whitespace-only and empty strings raise
`CurrencyProviderError(MISSING_CONFIG)`. `config.baseUrl` defaults to
`https://pro-api.coinmarketcap.com/v1`. `config.fetch` defaults to
`globalThis.fetch`.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
issues `GET <baseUrl>/cryptocurrency/quotes/latest?symbol=<csv>&convert=<base>`
through `@suaveplan/currency-providers`'s `httpRequest()`. Returns
`{ base, rates, timestamp }` with rates inverted into base-per-unit
form. Forwards `options.signal` to the underlying fetch.
### Error codes (all from `CURRENCY_PROVIDER_CODES`)
- `MISSING_CONFIG` — empty / whitespace `apiKey` at construction.
- `HTTP_ERROR` — non-2xx HTTP response (raised by `httpRequest()`).
- `PARSE_ERROR` — invalid JSON, non-object payload, or missing `data`.
- `UPSTREAM_ERROR` — payload `status.error_code` populated and non-zero.
- `UNKNOWN_CURRENCY` — none of the requested symbols resolved.
## Implementation Notes
- The default target list (`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`)
mirrors the upstream `../suaveplan/...coinmarketcap.ts` source —
these are the top ten cryptocurrencies by market cap and are the
same set every consumer of the upstream package received by default.
- Price inversion (`1 / price`) is applied per-symbol so a symbol with
a missing, non-numeric, or non-positive `price` is silently dropped
from the result. If the upstream returns `{ data: {} }` or every
symbol fails the numeric guard, the whole call raises
`UNKNOWN_CURRENCY` — the converter then falls back to its next
registered provider rather than caching a zero-entry result.
- `status.error_code` is treated as "present and non-zero" — many
CoinMarketCap success responses carry `status.error_code: 0`, which
is not a real error. The dedicated `assertNoUpstreamError` guard
preserves that distinction.
- The `parsePayload` step is intentionally lenient about the order of
validation: when both `status.error_code` and a missing `data` field
are present, the upstream error wins so consumers see the actionable
`UPSTREAM_ERROR` (and `upstreamMessage`) rather than a generic
`PARSE_ERROR`.
- All HTTP plumbing — abort-signal composition, timeout derivation,
non-2xx mapping to `HTTP_ERROR` — is delegated to
`@suaveplan/currency-providers`'s `httpRequest()`. The provider
never calls the injected `FetchLike` directly, so timeout / abort
behaviour is identical to every other sibling.

View File

@@ -0,0 +1,75 @@
# @suaveplan/currency-provider-cryptocompare - Universal CryptoCompare crypto-rates provider for @suaveplan/currency. Sibling provider package on the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). Optional API key — free tier works keyless; key boosts the rate limit and is sent via `authorization: Apikey <key>` header.
Universal CryptoCompare crypto-rates provider for `@suaveplan/currency`.
Sibling provider package on top of the slim `@suaveplan/currency-providers`
core (`httpRequest`, `CurrencyProviderError`, `NoKeyConfig`). Optional API
key — CryptoCompare's free tier works fully keyless; supplying a key boosts
the upstream rate limit and is sent on every request via the
`authorization: Apikey <key>` header.
<!-- begin:auto-docs -->
## Documentation tree
- [cryptocompare](./src/cryptocompare/cryptocompare.md) — `CryptoCompareProvider` implements the `CurrencyProvider` contract from `@suaveplan/currency` on top of CryptoCompare's…
- [cryptocompare](./src/cryptocompare/cryptocompare.md) — `CryptoCompareProvider` implements the `CurrencyProvider` contract from `@suaveplan/currency` on top of CryptoCompare's…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-cryptocompare \
@suaveplan/currency \
@suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const provider = new CryptoCompareProvider({
apiKey: process.env.CRYPTOCOMPARE_API_KEY, // optional
});
const converter = new CurrencyConverter({
provider,
staleDurationMs: 60_000,
});
const conversion = await converter.convert(amount, "BTC");
```
Or register through the slim-core registry:
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const registry = new CurrencyProviderRegistry();
registry.register(new CryptoCompareProvider());
const provider = registry.get("cryptocompare");
```
## Modules
- [`cryptocompare/cryptocompare`](./src/cryptocompare/cryptocompare.md) —
`CryptoCompareProvider` class, `CryptoCompareConfig` interface, error
mapping for `HTTP_ERROR` / `PARSE_ERROR` / `UPSTREAM_ERROR` /
`UNKNOWN_CURRENCY`.
## Errors
Every failure path raises `CurrencyProviderError` from the slim core:
| Code | Raised when |
|---|---|
| `CURRENCY_PROVIDER.HTTP_ERROR` | Non-2xx HTTP response (shared `httpRequest`) |
| `CURRENCY_PROVIDER.PARSE_ERROR` | Body not valid JSON, or payload not a JSON object |
| `CURRENCY_PROVIDER.UPSTREAM_ERROR` | Payload carries `Response: "Error"` |
| `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` | No requested symbol produced a finite positive quote |
Dispatch on `error.code`, not the message string. The instance also
carries `providerName: "cryptocompare"`, optional `status` (for HTTP_ERROR),
and optional `upstreamMessage` (for UPSTREAM_ERROR).

View File

@@ -0,0 +1,146 @@
# `cryptocompare` — CryptoCompare crypto-rates provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CryptoCompareProvider` implements the `CurrencyProvider` contract from
`@suaveplan/currency` on top of CryptoCompare's public
`min-api.cryptocompare.com/data/pricemulti` endpoint. It returns inverted
spot prices (`1 / price`) for a configurable set of crypto symbols against
a fiat or crypto base currency, suitable for direct registration with
`CurrencyProviderRegistry` or instantiation into `CurrencyConverter`.
The provider is sibling to the slim `@suaveplan/currency-providers` core:
all HTTP plumbing (timeout / abort composition / typed-error mapping) lives
in the shared `httpRequest` helper, the typed error class and code registry
come from `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES`, and the
config shape extends `NoKeyConfig` with one extra optional field. The
provider is universal — no Node, DOM, or framework dependencies — so
it runs untouched in browsers, Workers, Bun, and Node 18+.
## Features
- Sibling crypto-rates provider — `name: "cryptocompare"`,
`supportsCrypto: true`, `supportsFiat: false`
- Optional `apiKey` — free tier works fully keyless; key boosts the
upstream rate limit and is sent via `authorization: Apikey <key>` header
- 15 sensible default crypto symbols (BTC, ETH, SOL, USDT, USDC, BNB, XRP,
ADA, DOGE, DOT, AVAX, LINK, UNI, ATOM, LTC) when callers omit `targets`
- Injectable `FetchLike` from `@suaveplan/types` — tests pass a controlled
closure without `vi.stubGlobal('fetch', ...)`
- Typed errors via the slim core registry: `HTTP_ERROR`, `PARSE_ERROR`,
`UPSTREAM_ERROR`, `UNKNOWN_CURRENCY` — every failure path carries
`providerName: "cryptocompare"` for log dispatch
- Caller `AbortSignal` from `RateFetchOptions.signal` is composed through
`httpRequest`; whitespace-only `apiKey` strings are treated as absent
## Usage: Basic Example
```ts
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const provider = new CryptoCompareProvider();
const result = await provider.fetchRates({ base: "USD" });
console.log(result.rates.BTC); // 1 / spot price of BTC in USD
```
## Usage: Advanced Example
Register the provider with `CurrencyProviderRegistry`, use the keyed
upstream for higher rate limits, and dispatch on typed errors:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
CurrencyProviderRegistry,
} from "@suaveplan/currency-providers";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const registry = new CurrencyProviderRegistry();
registry.register(
new CryptoCompareProvider({
apiKey: process.env.CRYPTOCOMPARE_API_KEY,
baseUrl: "https://min-api.cryptocompare.com/data",
}),
);
const converter = new CurrencyConverter({
provider: registry.get("cryptocompare"),
staleDurationMs: 60_000,
});
try {
const conversion = await converter.convert(amount, "BTC");
console.log(conversion);
} catch (error) {
if (
error instanceof CurrencyProviderError &&
error.code === CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
) {
console.warn("CryptoCompare upstream:", error.upstreamMessage);
} else {
throw error;
}
}
```
## API Reference
- `interface CryptoCompareConfig extends NoKeyConfig`
- `apiKey?: string` — optional API key. Sent on every request via
`authorization: Apikey <key>` when present and non-whitespace.
Whitespace-only strings are treated as absent.
- `baseUrl?: string` — overrides the default
`https://min-api.cryptocompare.com/data` endpoint.
- `fetch?: FetchLike` — injectable WHATWG-shape fetch. Defaults to
`globalThis.fetch`.
- `class CryptoCompareProvider implements CurrencyProvider`
- `name: "cryptocompare"`, `supportsCrypto: true`,
`supportsFiat: false`.
- `constructor(config?: CryptoCompareConfig)` — every config field is
optional. Constructing without a config wires `globalThis.fetch`, the
default base URL, and no API key.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
issues a single `GET <baseUrl>/pricemulti?fsyms=<targets>&tsyms=<base>`,
inverts each price, and returns `{ base, rates, timestamp }`.
`options.base` defaults to `"USD"`; `options.targets` defaults to the
15 built-in symbols when omitted or empty; `options.signal` is
forwarded into the shared `httpRequest` helper.
- Raised `CurrencyProviderError` codes:
- `HTTP_ERROR` — non-2xx response (raised by shared `httpRequest`).
- `PARSE_ERROR` — body is not valid JSON, or the parsed payload is not
a JSON object.
- `UPSTREAM_ERROR` — payload carries `Response: "Error"`; the
`Message` field (or `"unknown upstream error"` when absent) is copied
to `error.upstreamMessage`.
- `UNKNOWN_CURRENCY` — no requested symbol produced a finite positive
quote in the requested base currency.
## Implementation Notes
- CryptoCompare's `pricemulti` endpoint returns HTTP 200 even for
per-symbol upstream errors — the payload carries `Response: "Error"` and
a human-readable `Message`. The provider distinguishes that from a
successful (possibly empty) quote map and surfaces it as
`UPSTREAM_ERROR` rather than masquerading as `HTTP_ERROR`.
- The free tier permits ~100,000 calls per month without a key; supplying
an `apiKey` boosts that quota. The provider authenticates via the
`authorization: Apikey <key>` header per CryptoCompare's documentation
(Apikey scheme, not Bearer). Empty or whitespace-only keys are silently
treated as absent so callers can pass `process.env.CRYPTOCOMPARE_API_KEY`
directly without a guard.
- Prices are inverted (`1 / price`) so the result map answers "one unit of
`base` is worth X units of `symbol`" — consistent with every other
sibling provider. Non-finite or non-positive quotes are skipped.
- The default symbol list covers the top 15 crypto assets by market cap at
package-publish time; callers wanting a different set always pass
`targets` explicitly. The defaults exist only as a developer-friendly
fallback for ad-hoc usage.
- The provider does not wrap retry, backoff, or rate-limit semantics —
compose `@suaveplan/retry` / future `RateLimitedProvider` wrappers at a
higher layer, exactly the same way as every other sibling provider.

View File

@@ -0,0 +1,66 @@
# @suaveplan/currency-provider-ecb - Universal @suaveplan/currency provider for the European Central Bank (ECB) daily exchange-rates XML feed. Free, no API key. Implements the slim @suaveplan/currency-providers contract: typed CurrencyProviderError, injectable FetchLike from @suaveplan/types, shared httpRequest helper, mathematical conversion to any non-EUR base, AbortSignal propagation, target filtering, PARSE_ERROR / UNSUPPORTED_BASE / UNKNOWN_CURRENCY / HTTP_ERROR coverage.
Universal `@suaveplan/currency` rate provider for the European Central
Bank's daily exchange-rates XML feed. Free, no API key required. Built on the
slim `@suaveplan/currency-providers` plugin core: typed
`CurrencyProviderError` on every failure path, shared `httpRequest()` helper
for HTTP wrapping + abort-signal composition, injectable `FetchLike` from
`@suaveplan/types`, mathematical conversion to any non-EUR base, target
filtering with `UNKNOWN_CURRENCY` guarding, and `PARSE_ERROR` protection
against silently caching an empty rate map.
<!-- begin:auto-docs -->
## Documentation tree
- [ecb](./src/ecb/ecb.md) — `EcbProvider` is the `@suaveplan/currency` rate-source implementation for the European Central Bank's daily reference-r…
- [ecb](./src/ecb/ecb.md) — `EcbProvider` is the `@suaveplan/currency` rate-source implementation for the European Central Bank's daily reference-r…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-ecb @suaveplan/currency @suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const converter = new CurrencyConverter({ provider: new EcbProvider() });
const rate = await converter.getRate("EUR", "USD");
```
Or via the slim core's registry, alongside other sibling provider packages:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
const converter = new CurrencyConverter({ provider: registry.get("ecb") });
```
## Features
- Free, no API key.
- ~30 fiat currencies, EUR-quoted upstream; non-EUR bases derived
arithmetically.
- Daily refresh (~16:00 CET); weekends / holidays return the last published
snapshot.
- `RateFetchOptions.signal` forwarded through the slim core's HTTP helper.
- Typed `CurrencyProviderError` codes: `HTTP_ERROR`, `PARSE_ERROR`,
`UNSUPPORTED_BASE`, `UNKNOWN_CURRENCY`.
- Universal (browser / Web Worker / Node 18+ / Bun).
- Zero runtime deps beyond `@suaveplan/currency`,
`@suaveplan/currency-providers`, `@suaveplan/types`, `tslib`.
## Modules
- [`src/ecb/ecb.md`](./src/ecb/ecb.md) — `EcbProvider` class, configuration,
error matrix, advanced examples.

View File

@@ -0,0 +1,143 @@
# EcbProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EcbProvider` is the `@suaveplan/currency` rate-source implementation for the
European Central Bank's daily reference-rates XML feed. It is the lowest-
friction fiat provider in the Suaveplan catalogue: free, no API key required,
universal (runs in browsers, Web Workers, Node.js, and Bun), and built on
the slim `@suaveplan/currency-providers` plugin core so every error path,
abort-signal composition, and HTTP wrapping is shared with every other sibling
provider in the family.
The package exposes a single class, `EcbProvider`, with stable identity flags
(`name === "ecb"`, `supportsFiat === true`, `supportsCrypto === false`). It is
not auto-registered with any registry — consumers wire it through either
`new CurrencyConverter({ provider: new EcbProvider() })` or
`registry.register(new EcbProvider())` against a
`CurrencyProviderRegistry` from `@suaveplan/currency-providers`.
## Features
- Free, no authentication — public ECB daily XML feed.
- ~30 fiat currencies (every major plus many minor European / global pairs).
- Updates around 16:00 CET each business day; weekends and bank holidays
return the last published snapshot.
- Default base is EUR; any non-EUR base is synthesised by inverting through
the EUR rates so callers never have to triangulate manually.
- Optional `targets` filter so consumers iterating a small basket avoid
caching the full 30-currency table.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR` for non-
2xx upstream responses, `PARSE_ERROR` for an unparseable body,
`UNSUPPORTED_BASE` when the requested base is missing from the EUR-quoted
table, `UNKNOWN_CURRENCY` when every requested target is absent.
- `AbortSignal` propagation — `RateFetchOptions.signal` is forwarded through
the slim core's `httpRequest()` so callers can cancel in-flight requests.
- Injectable `FetchLike` (from `@suaveplan/types`) — tests pass a recording
closure; production accepts the default `globalThis.fetch`.
- Zero runtime dependencies beyond `@suaveplan/currency-providers` /
`@suaveplan/currency` / `@suaveplan/types` / `tslib`.
## Usage
### Basic Example
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const converter = new CurrencyConverter({
provider: new EcbProvider(),
});
const rate = await converter.getRate("EUR", "USD");
console.log(`1 EUR = ${rate} USD`);
```
### Advanced Example
Wiring through the slim core's registry with a custom `baseUrl` (e.g. an
internal proxy that caches the upstream XML at the edge) and a caller-side
`AbortSignal` so a UI cancellation aborts the in-flight HTTP request.
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const registry = new CurrencyProviderRegistry();
registry.register(
new EcbProvider({
baseUrl: "https://cdn.internal.example.com/ecb/daily.xml",
}),
);
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("user navigated away")), 5_000);
const provider = registry.get("ecb");
const result = await provider.fetchRates({
base: "USD",
targets: ["EUR", "GBP", "JPY"],
signal: controller.signal,
});
console.log(result.base, result.rates, result.timestamp);
```
## API Reference
### `class EcbProvider implements CurrencyProvider`
#### `constructor(config?: NoKeyConfig)`
- `config.baseUrl?: string` — override the default ECB endpoint
(`https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml`).
- `config.fetch?: FetchLike` — injectable `fetch` implementation; defaults to
`globalThis.fetch` resolved at call time.
#### Read-only fields
- `name: "ecb"` — stable identifier surfaced as `CurrencyProvider.name`.
- `supportsFiat: true`
- `supportsCrypto: false`
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
Fetches and returns the latest ECB rates. `options.base` defaults to `"EUR"`;
when set to any other currency the result is synthesised by inverting through
the EUR-quoted feed. `options.targets` filters the returned map.
`options.signal` is forwarded to the underlying HTTP call.
**Throws** `CurrencyProviderError` with one of:
- `HTTP_ERROR` — non-2xx response (raised by the slim core's `httpRequest`).
- `PARSE_ERROR` — response body contained no parseable currency tags.
- `UNSUPPORTED_BASE` — caller asked for a non-EUR base that is not in the
EUR-quoted feed.
- `UNKNOWN_CURRENCY` — every entry in `options.targets` was absent from the
resolved rate map.
## Implementation Notes
- The ECB endpoint always publishes EUR-based rates. Non-EUR bases are
derived purely arithmetically — there is no second HTTP call. This is
the same algorithm the upstream `@suaveplan/currency-providers` shipped
before the 2026-05-17 slim refactor split it into siblings.
- XML parsing uses a regex (`currency='([A-Z]+)'\s+rate='([\d.]+)'`) rather
than a full XML parser to keep the package zero-runtime-dep beyond the
slim core and `@suaveplan/currency`. The ECB feed shape is stable; if the
upstream format changes, the failure surfaces immediately as
`PARSE_ERROR`.
- Rates are not available on weekends or ECB bank holidays; the feed returns
the last published snapshot. Consumers that need same-day freshness should
layer `@suaveplan/currency`'s `staleDurationMs` on top.
- `globalThis.fetch` is resolved at call time, not constructor time, so test
setups that replace the global between construction and the first
`fetchRates()` call still work.
- The package declares `"sideEffects": false` and ships explicit named barrels
per AGENTS.md §6a; tree-shaking unused exports is safe.

View File

@@ -0,0 +1,89 @@
# @suaveplan/currency-provider-fixer - Fixer.io fiat-rates CurrencyProvider sibling of @suaveplan/currency-providers. API-key required; 170+ fiat currencies; maps upstream error codes (105 → UNSUPPORTED_BASE, 201/202 → UNKNOWN_CURRENCY, other success:false → UPSTREAM_ERROR) to the slim core's typed CurrencyProviderError. Universal, FetchLike-injectable.
[Fixer.io](https://fixer.io/) fiat-rates sibling provider for the slim
`@suaveplan/currency-providers` core. Implements the `CurrencyProvider`
contract from `@suaveplan/currency`, requires an API key, and maps the
upstream `{ success, error: { code, info } }` envelope to typed
`CurrencyProviderError` codes — including the free-tier
`base_currency_access_restricted` case (`UNSUPPORTED_BASE`).
<!-- begin:auto-docs -->
## Documentation tree
- [fixer](./src/fixer/fixer.md) — `FixerProvider` is the Fixer.io sibling provider for the slim `@suaveplan/currency-providers` core. It implements the `…
- [fixer](./src/fixer/fixer.md) — `FixerProvider` is the Fixer.io sibling provider for the slim `@suaveplan/currency-providers` core. It implements the `…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-fixer \
@suaveplan/currency-providers \
@suaveplan/currency
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
const registry = new CurrencyProviderRegistry();
registry.register(new FixerProvider({ apiKey: process.env.FIXER_KEY! }));
const converter = new CurrencyConverter({
provider: registry.get("fixer"),
staleDurationMs: 3_600_000,
});
const rate = await converter.getRate("EUR", "GBP");
```
Direct instantiation without the registry is equally supported:
```ts
const provider = new FixerProvider({ apiKey: process.env.FIXER_KEY! });
const result = await provider.fetchRates();
```
## Features
- Implements `CurrencyProvider` (`name = "fixer"`, `supportsFiat = true`,
`supportsCrypto = false`).
- Constructor takes the slim core's shared `ApiKeyConfig`; an empty
`apiKey` raises `CURRENCY_PROVIDER.MISSING_CONFIG` synchronously
before any network call (no wasted quota).
- HTTP transport via the slim core's `httpRequest` — `AbortSignal`
composition, non-2xx → typed `HTTP_ERROR`, `providerName` / `status`
populated on the raised error.
- Typed upstream-code mapping: `105` → `UNSUPPORTED_BASE`, `201` / `202`
→ `UNKNOWN_CURRENCY`, any other `success: false` → `UPSTREAM_ERROR`
with `upstreamMessage` carrying the upstream `error.info`.
- `PARSE_ERROR` for malformed JSON, non-object JSON, and successful
envelopes missing required fields.
- Universal — runs unchanged under Node 18+, Bun, modern browsers,
and Web Workers.
- One subpath export, one feature module; explicit named barrels;
100/100/100/100 istanbul coverage.
## Modules
- [`src/fixer/fixer.md`](./src/fixer/fixer.md) — `FixerProvider` class,
envelope handling, error-code mapping table.
## Sibling packages
Part of the foundation-with-sibling-plugins family established by
the design document. Other concrete
providers (ECB, Open Exchange Rates, Binance, CoinGecko, CoinMarketCap,
CryptoCompare, Kraken, Virtual) ship as separate
`@suaveplan/currency-provider-<name>` packages.
## Status
`0.1.0` (pre-graduation). New native sibling landed on
2026-05-17 against the slimmed core. See
the design proposal
for the proposal, design, and parity checklist.

View File

@@ -0,0 +1,154 @@
# FixerProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FixerProvider` is the [Fixer.io](https://fixer.io/) sibling provider for
the slim `@suaveplan/currency-providers` core. It implements the
`CurrencyProvider` contract from `@suaveplan/currency`, fetches the
latest fiat exchange rates from the Fixer `/latest` REST endpoint, and
maps the upstream JSON envelope into the canonical `RateFetchResult`
shape the converter expects.
Fixer.io requires an API key on every request. Free-tier accounts are
restricted to `base = "EUR"` and a fixed monthly request quota; paid plans
unlock arbitrary base currencies and higher throughput. The provider
surfaces the free-tier restriction as a typed `UNSUPPORTED_BASE` error so
consumers can dispatch on the discriminator rather than parsing the
upstream message string.
## Features
- Implements `CurrencyProvider` with stable identity flags
(`name = "fixer"`, `supportsFiat = true`, `supportsCrypto = false`).
- Constructor takes the slim core's shared `ApiKeyConfig` (`apiKey`,
optional `baseUrl`, optional injectable `fetch`). An empty `apiKey`
raises `CURRENCY_PROVIDER.MISSING_CONFIG` synchronously before any
network call is issued, preventing wasted quota.
- Delegates HTTP transport to the slim core's `httpRequest` helper —
`AbortSignal` composition, non-2xx → typed `HTTP_ERROR` mapping, and
`providerName` / `status` carry on the raised error.
- Maps the upstream `{ success: false, error: { code, info } }` envelope
to typed codes: `105` → `UNSUPPORTED_BASE`, `201` / `202` →
`UNKNOWN_CURRENCY`, any other code → `UPSTREAM_ERROR`. The upstream
`error.info` is preserved on `upstreamMessage` so diagnostics survive.
- Distinguishes parse failures (non-JSON, non-object JSON, success
envelope missing required fields) under a single `PARSE_ERROR` code.
- Universal — no `node:` / `bun:` / DOM imports; runs unchanged
under Node 18+, Bun, modern browsers, and Web Workers.
## Usage: Basic
```ts
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
const provider = new FixerProvider({ apiKey: process.env.FIXER_KEY! });
// Free-tier: base is implicitly EUR.
const result = await provider.fetchRates();
console.log(result.base, result.rates.GBP, result.timestamp);
```
## Usage: Advanced
Wire the provider through the slim core's `CurrencyProviderRegistry` and
use it as the primary source for a `CurrencyConverter`. The injected
`FetchLike` makes the provider trivial to unit-test against a fixture
fetch closure:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
const registry = new CurrencyProviderRegistry();
registry.register(
new FixerProvider({
apiKey: process.env.FIXER_KEY!,
// Optional: proxy through your own gateway for caching / auditing.
baseUrl: "https://rates.internal/fixer/api",
// Optional: inject a wrapped fetch for retries or logging.
fetch: async (url, init) => {
const response = await globalThis.fetch(url, init);
return response;
},
}),
);
const converter = new CurrencyConverter({
provider: registry.get("fixer"),
staleDurationMs: 3_600_000,
});
// Free-tier accounts MUST keep base = "EUR" — supplying any other base
// raises CurrencyProviderError(UNSUPPORTED_BASE) via the typed mapping
// from upstream error code 105.
const rate = await converter.getRate("EUR", "GBP");
```
## API Reference
### `class FixerProvider implements CurrencyProvider`
- `readonly name = "fixer"`
- `readonly supportsCrypto = false`
- `readonly supportsFiat = true`
#### `constructor(config: ApiKeyConfig)`
Validates `config.apiKey` is non-empty (throws
`CurrencyProviderError(MISSING_CONFIG)` otherwise) and caches
`config.baseUrl ?? "https://data.fixer.io/api"` and
`config.fetch ?? globalThis.fetch`.
**Throws**: `CurrencyProviderError(MISSING_CONFIG)` when `apiKey === ""`.
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
Builds `?access_key=…&base=…[&symbols=…]`, delegates to the slim core's
`httpRequest`, parses the JSON envelope, and returns the canonical
`RateFetchResult` with `timestamp` converted from Unix seconds to
milliseconds.
**Parameters** (all optional): `base` (default `"EUR"`), `targets`
(array of currency codes — joined into `symbols`), `signal`
(`AbortSignal` forwarded to the underlying fetch).
**Returns**: `{ base, rates, timestamp }` where `timestamp` is
milliseconds since epoch.
**Throws** (all `CurrencyProviderError`):
- `MISSING_CONFIG` — at construction when `apiKey` is empty.
- `HTTP_ERROR` — non-2xx upstream response (via `httpRequest`).
- `PARSE_ERROR` — body is not JSON, parsed value is not an object, or
a `success: true` envelope is missing `base` / `rates` / `timestamp`.
- `UNSUPPORTED_BASE` — upstream code `105` (free-tier base restriction).
- `UNKNOWN_CURRENCY` — upstream code `201` (`invalid_base_currency`) or
`202` (`invalid_currency_codes`).
- `UPSTREAM_ERROR` — any other `success: false` envelope, including the
case where `error` is absent.
## Implementation Notes
- Fixer.io returns HTTP 200 even for application-level errors; the
provider must therefore parse every body and discriminate on
`success`. The non-2xx path (`HTTP_ERROR`) is reserved for genuine
network / gateway failures.
- The upstream `error.code` integer is the discriminator, not
`error.type` — the integer is one comparison and avoids string
allocation. The human-readable `error.info` is preserved on
`upstreamMessage` for diagnostics.
- The `apiKey` non-empty guard at construction is suaveplan-native — the
upstream skeleton allowed an empty string and let the API reject on the
wire, wasting the request against the monthly quota.
- `fetchRates` forwards `options.signal` through `httpRequest` so the
caller can cancel an in-flight request from outside the provider; this
too is suaveplan-native (upstream did not propagate the signal).
- The package is universal and free of side effects (`sideEffects:
false`). Importing the package never mutates any global registry — the
caller decides whether to register the instance with
`CurrencyProviderRegistry`.

View File

@@ -0,0 +1,87 @@
# @suaveplan/currency-provider-kraken - Universal Kraken public-ticker rate provider for @suaveplan/currency. Wraps Kraken's free no-API-key /0/public/Ticker endpoint into a CurrencyProvider with the slim core's shared httpRequest helper, typed CurrencyProviderError (HTTP_ERROR / PARSE_ERROR / UPSTREAM_ERROR / UNKNOWN_CURRENCY), and the 12-symbol BTC/ETH/SOL/USDT/DOT/ADA/LINK/AVAX/ATOM/LTC/DOGE/XRP coverage matched against Kraken's non-standard pair names.
Universal Kraken public-ticker rate provider for `@suaveplan/currency`.
Wraps Kraken's free, no-API-key `/0/public/Ticker` endpoint into a
`CurrencyProvider` so a `CurrencyConverter` can price the major crypto
pairs against USD without callers ever seeing Kraken's non-standard pair
names (`XXBTZUSD`, `XETHZUSD`, `XDGUSD`, …).
Concrete sibling of the slim `@suaveplan/currency-providers` core — uses
the shared `httpRequest()` helper, raises the shared `CurrencyProviderError`
codes (`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`),
and accepts the shared `NoKeyConfig` (optional `baseUrl`, optional injectable
`FetchLike`) plus an optional `extraSymbols` map for caller-supplied
extensions.
<!-- begin:auto-docs -->
## Documentation tree
- [provider](./src/provider/provider.md) — `KrakenProvider` is the universal `CurrencyProvider` implementation for Kraken's free `/0/public/Ticker` endpoint.…
- [provider](./src/provider/provider.md) — `KrakenProvider` is the universal `CurrencyProvider` implementation for Kraken's free `/0/public/Ticker` endpoint.…
- [symbols](./src/symbols/symbols.md) — The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD` for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUS…
- [symbols](./src/symbols/symbols.md) — The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD` for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUS…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-kraken @suaveplan/currency @suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { KrakenProvider } from "@suaveplan/currency-provider-kraken";
const provider = new KrakenProvider();
const converter = new CurrencyConverter({ provider });
const btcPerUsd = await converter.getRate("USD", "BTC"); // e.g. 0.0000149
const ethPerUsd = await converter.getRate("USD", "ETH"); // e.g. 0.0002857
```
Or register through the slim core's registry:
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { KrakenProvider } from "@suaveplan/currency-provider-kraken";
const registry = new CurrencyProviderRegistry();
registry.register(new KrakenProvider());
const provider = registry.get("kraken");
```
## Features
- No API key — Kraken's public endpoint is free (per-IP rate-limited)
- 12 high-volume symbols out of the box: BTC, ETH, SOL, USDT, DOT, ADA,
LINK, AVAX, ATOM, LTC, DOGE, XRP
- Optional `extraSymbols` for newly-listed pairs without forking
- Reciprocal-of-USD rate model — `rates[X] = 1 / lastTradeClosedPriceUsd`
- Single-round-trip batch fetch (Kraken supports a comma-separated `pair=`
query)
- Uses the slim core's `httpRequest()` so HTTP errors, timeouts, and caller
`AbortSignal` propagation are uniform across providers
- Maps wire-level failure modes to typed `CurrencyProviderError`:
`HTTP_ERROR`, `UPSTREAM_ERROR`, `PARSE_ERROR`, `UNKNOWN_CURRENCY`
- Universal — no `node:`/`bun:`/DOM imports; runs unchanged under
Node ≥ 18, Bun, modern browsers (subject to Kraken's CORS policy), Web
Workers
## Modules
- [`src/provider/provider.md`](./src/provider/provider.md) —
`KrakenProvider` class, `KrakenProviderConfig`, `DEFAULT_KRAKEN_BASE_URL`
- [`src/symbols/symbols.md`](./src/symbols/symbols.md) —
`KRAKEN_SYMBOL_MAP`, `KRAKEN_PAIR_TO_SYMBOL`, `mergeSymbolMap`
## Status
`0.1.0` (pre-graduation). native sibling of the slim
`@suaveplan/currency-providers` core landed by
the design document. See
the design proposal for
the migration parity table and source mapping.

View File

@@ -0,0 +1,130 @@
# `provider` — KrakenProvider class
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`KrakenProvider` is the universal `CurrencyProvider` implementation
for Kraken's free `/0/public/Ticker` endpoint. It fetches real-time
last-trade-closed prices for a fixed set of high-volume crypto pairs and
returns them as reciprocal-of-USD rates, so a downstream `CurrencyConverter`
can translate `USD → BTC` (and friends) without callers ever seeing
Kraken's non-standard pair names.
The class is the canonical sibling-package surface defined by the slim
`@suaveplan/currency-providers` core: it wraps a single upstream endpoint,
maps every documented failure mode to a typed `CurrencyProviderError`, and
exposes nothing that consumers can't already obtain from the shared core
helpers.
## Features
- No API key required — Kraken's public endpoint is free and rate-limited
per IP
- 12 high-volume symbols out of the box (BTC, ETH, SOL, USDT, DOT, ADA,
LINK, AVAX, ATOM, LTC, DOGE, XRP); extensible via constructor
`extraSymbols`
- Reciprocal-of-USD rate model — `1 USD = (1 / krakenLastTradePrice)` of the
target crypto, matching the shape every `CurrencyConverter` consumer expects
- Uses the slim core's `httpRequest()` helper so HTTP failures, timeouts,
and caller abort-signal propagation are handled uniformly
- Maps the four wire-level failure modes to typed `CurrencyProviderError`:
`HTTP_ERROR` (non-2xx), `UPSTREAM_ERROR` (Kraken `error[]` array),
`PARSE_ERROR` (malformed JSON / shape mismatch), `UNKNOWN_CURRENCY` (all
requested targets unmapped)
- Universal — no `node:`/`bun:`/DOM imports; runs unchanged under
Node ≥ 18, Bun, modern browsers (subject to Kraken's CORS), Web Workers
- Optional injectable `FetchLike` (defaults to `globalThis.fetch`) for
recording tests, proxying, or staging URLs
## Usage: Basic
```ts
import { KrakenProvider } from "@suaveplan/currency-provider-kraken/provider/provider";
const provider = new KrakenProvider();
const result = await provider.fetchRates({ base: "USD" });
console.log(result.rates.BTC); // e.g. 0.0000149 (≈ 1 / 67000)
console.log(result.rates.ETH); // e.g. 0.0002857 (≈ 1 / 3500)
```
## Usage: Advanced
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { KrakenProvider } from "@suaveplan/currency-provider-kraken/provider/provider";
// Use the slim core's registry — wire Kraken plus any other sibling
// providers your app supports. The Kraken instance extends coverage with
// a newly-listed pair via `extraSymbols`, and reuses a custom fetch
// closure that adds a proxy header.
const registry = new CurrencyProviderRegistry();
registry.register(
new KrakenProvider({
baseUrl: "https://kraken.proxy.example.com/0/public",
fetch: async (url, init) =>
fetch(url, {
...init,
headers: {
...(init?.headers ?? {}),
"x-proxy-token": process.env.PROXY_TOKEN ?? "",
},
}),
extraSymbols: { MATIC: "MATICUSD" },
}),
);
const converter = new CurrencyConverter({
provider: registry.get("kraken"),
});
const btcRate = await converter.getRate("USD", "BTC");
const maticRate = await converter.getRate("USD", "MATIC");
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `DEFAULT_KRAKEN_BASE_URL` | `string` | `"https://api.kraken.com/0/public"` — overridable via `config.baseUrl`. |
| `KrakenProviderConfig` | `interface` extends `NoKeyConfig` | Optional `baseUrl`, optional `fetch` (`FetchLike`), optional `extraSymbols` (`Record<string, string>`). |
| `KrakenProvider` | `class implements CurrencyProvider` | Fixed `name = "kraken"`, `supportsCrypto = true`, `supportsFiat = false`. Constructor takes an optional `KrakenProviderConfig`. |
| `KrakenProvider.fetchRates(options?)` | `(RateFetchOptions?) => Promise<RateFetchResult>` | Issues one `GET /Ticker?pair=<csv>` via the injected fetch. Returns `{ base, rates, timestamp }`. Throws `CurrencyProviderError` on every documented failure path. |
### Error codes raised
| Code | When |
|---|---|
| `CURRENCY_PROVIDER.HTTP_ERROR` | Non-2xx response (raised by the shared `httpRequest`). Carries `status`. |
| `CURRENCY_PROVIDER.UPSTREAM_ERROR` | Kraken response carries a non-empty `error[]` array. Carries `upstreamMessage`. |
| `CURRENCY_PROVIDER.PARSE_ERROR` | Body fails JSON-parse, OR the parsed body does not match the `{ error: string[], result: object }` shape. Carries `cause` for the JSON-parse case. |
| `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` | Caller supplied `options.targets` but none of the requested symbols resolved to a mapped Kraken pair. |
## Implementation Notes
- The pair list sent in the `pair=` query is the value list of the merged
symbol map — Kraken supports a comma-separated batch so the whole supported
set is fetched in a single round trip. This is more efficient than one
request per symbol AND matches what Kraken's documentation recommends.
- The rate model is `1 / Number.parseFloat(ticker.c[0])` where `c[0]` is
Kraken's "last trade closed" price. Entries whose price does not parse
to a positive finite number are skipped (not raised) — Kraken occasionally
returns `"0.00"` for freshly-listed pairs. If the entire mapped set drops,
`options.targets` filtering will subsequently raise `UNKNOWN_CURRENCY`
on the caller's behalf.
- `options.signal` is forwarded into `httpRequest()` so the caller's
`AbortController` composes with any future timeout the helper applies.
- The provider does **not** retry, cache, or rate-limit. Those concerns
belong in wrapper providers (the slim core's `## Out of scope` defers
them) — keeping this class focused on Kraken's wire protocol keeps the
failure surface auditable.
- All HTTP error mapping flows through the shared `httpRequest()` helper
exported from `@suaveplan/currency-providers/http/http`, so the response
is never read on a non-2xx path — Kraken's body on a 5xx is typically
HTML and would otherwise raise a confusing `PARSE_ERROR`.
- Tier purity: no `node:` imports, no DOM imports, no `setTimeout`. The
helper uses `AbortSignal.timeout` for timeouts (universal in Node 17.3+,
Bun, modern browsers).

View File

@@ -0,0 +1,90 @@
# `symbols` — Kraken symbol-mapping table
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD`
for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUSD` for DOGE/USD — using the
asset-prefix convention (`X` for native crypto assets, `Z` for fiat) inherited
from its early-2014 instrument schema. Every consumer of the `KrakenProvider`
wants to address rates by the Suaveplan-standard symbol (`BTC`, `ETH`,
`DOGE`), so the provider needs a stable bidirectional lookup table that
normalises both directions in O(1).
This module owns that table. It exports the frozen forward map
(`KRAKEN_SYMBOL_MAP`), the derived frozen reverse map
(`KRAKEN_PAIR_TO_SYMBOL`), and a `mergeSymbolMap` helper for callers who
need to extend coverage without forking the package.
## Features
- 12 canonical Suaveplan symbols mapped to their Kraken pair names: BTC,
ETH, SOL, USDT, DOT, ADA, LINK, AVAX, ATOM, LTC, DOGE, XRP
- Forward + reverse maps both `Object.freeze`-d for identity stability and
cheap memoised lookups in the hot path
- `mergeSymbolMap(extras)` for caller-supplied extensions — returns a fresh
forward + reverse pair without mutating the canonical maps
- Zero runtime dependencies, universal
## Usage: Basic
```ts
import {
KRAKEN_PAIR_TO_SYMBOL,
KRAKEN_SYMBOL_MAP,
} from "@suaveplan/currency-provider-kraken/symbols/symbols";
console.log(KRAKEN_SYMBOL_MAP.BTC); // "XXBTZUSD"
console.log(KRAKEN_PAIR_TO_SYMBOL.XXBTZUSD); // "BTC"
```
## Usage: Advanced
```ts
import { mergeSymbolMap } from "@suaveplan/currency-provider-kraken/symbols/symbols";
// Extend with a pair Kraken added after this release shipped.
const { forward, reverse } = mergeSymbolMap({
MATIC: "MATICUSD",
APT: "APTUSD",
});
console.log(forward.MATIC); // "MATICUSD"
console.log(reverse.MATICUSD); // "MATIC"
// Original maps are unchanged — `mergeSymbolMap` never mutates them.
console.log("MATIC" in KRAKEN_SYMBOL_MAP); // false
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `KRAKEN_SYMBOL_MAP` | `Readonly<Record<string, string>>` | Frozen forward map: 12 Suaveplan symbols → Kraken pair name. |
| `KRAKEN_PAIR_TO_SYMBOL` | `Readonly<Record<string, string>>` | Frozen reverse map derived at module load from the forward map. |
| `MergedSymbolMaps` | `interface` | Shape returned by `mergeSymbolMap` — `{ forward, reverse }`, both `Readonly<Record<string, string>>`. |
| `mergeSymbolMap(extras?)` | `(Readonly<Record<string, string>> \| undefined) => MergedSymbolMaps` | Returns the canonical maps when `extras` is empty/undefined; otherwise returns fresh forward + reverse maps with the extras merged on top. |
## Implementation Notes
- The Kraken instrument schema mixes legacy `X<base>Z<quote>` pairs (`XXBTZUSD`)
with flat `<base>USD` pairs (`SOLUSD`, `DOTUSD`) added after the schema
redesign. The map captures both forms so callers don't have to.
- The forward map is intentionally small. Kraken lists hundreds of pairs;
the 12 included here are the high-volume subset that match the upstream
9-provider bundle's coverage. Callers needing more symbols use the
`extraSymbols` constructor field on `KrakenProvider` (which calls
`mergeSymbolMap` internally) or call `mergeSymbolMap` directly when they
want the maps for their own logic.
- `mergeSymbolMap` is a hot-path-aware fast path: when `extras` is empty or
undefined, it returns the canonical maps by reference without allocating.
- Both canonical maps are frozen, so `Object.assign(KRAKEN_SYMBOL_MAP, …)`
silently fails in non-strict mode and throws in strict mode — making
accidental mutation impossible.
- Determinism: iteration order of `Object.entries(KRAKEN_SYMBOL_MAP)` is
property-insertion order per the ES2015 spec, so derived structures
(including the reverse map and merged outputs) have deterministic ordering
for golden-file tests.

View File

@@ -0,0 +1,67 @@
# @suaveplan/currency-provider-open-exchange-rates - Universal @suaveplan/currency provider for openexchangerates.org. API-key (app_id) required. Free tier limited to USD base; paid tiers opt in via allowNonUsdBase. Routes every request through @suaveplan/currency-providers httpRequest() and raises typed CurrencyProviderError for HTTP / parse / upstream / config / base / currency failures.
Universal `@suaveplan/currency` provider for
[openexchangerates.org](https://openexchangerates.org). Routes `/latest.json`
through the slim-core `httpRequest()` helper, validates the upstream payload,
and raises typed `CurrencyProviderError` for every failure mode.
<!-- begin:auto-docs -->
## Documentation tree
- [provider](./src/provider/provider.md) — `OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for openexchangerates.org. It wraps the `/latest.json…
- [provider](./src/provider/provider.md) — `OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for openexchangerates.org. It wraps the `/latest.json…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-open-exchange-rates
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
const oxr = new OpenExchangeRatesProvider({
apiKey: process.env.OXR_APP_ID!,
// allowNonUsdBase: true, // paid plans only
});
const converter = new CurrencyConverter({ provider: oxr });
const usdToEur = await converter.getRate({ from: "USD", to: "EUR" });
console.log(usdToEur);
```
## Configuration
| Field | Type | Default | Notes |
| ----------------- | ----------- | --------------------------------------- | ---------------------------------------------------------------- |
| `apiKey` | `string` | — | OXR `app_id`. Empty string raises `MISSING_CONFIG`. |
| `baseUrl` | `string?` | `https://openexchangerates.org/api` | Override for staging / proxy fixtures. |
| `fetch` | `FetchLike?`| `globalThis.fetch` | Injectable for testing. |
| `allowNonUsdBase` | `boolean?` | `false` | Set `true` on paid plans to forward non-USD `base` requests. |
## Error codes
Every failure mode raises `CurrencyProviderError` from
`@suaveplan/currency-providers` with one of:
- `CURRENCY_PROVIDER.MISSING_CONFIG` — empty `apiKey`.
- `CURRENCY_PROVIDER.UNSUPPORTED_BASE` — non-USD base on free tier.
- `CURRENCY_PROVIDER.HTTP_ERROR` — non-2xx response (raised by `httpRequest()`).
- `CURRENCY_PROVIDER.PARSE_ERROR` — body is not JSON or missing required fields.
- `CURRENCY_PROVIDER.UPSTREAM_ERROR` — 2xx with OXR `{ error: true, … }` body.
- `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` — `options.targets` contains a code absent from the upstream rates map.
## Modules
- [`src/provider/provider.md`](./src/provider/provider.md) — `OpenExchangeRatesProvider` class and config.
## Tier
Universal — runs in Node 18+, Bun, browsers, and Web Workers without any
runtime polyfill.

View File

@@ -0,0 +1,148 @@
# `OpenExchangeRatesProvider`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for
[openexchangerates.org](https://openexchangerates.org). It wraps the
`/latest.json` bulk-fetch endpoint, validates the upstream payload, and
surfaces every failure mode through the typed `CurrencyProviderError`
hierarchy from `@suaveplan/currency-providers`. The provider is universal
— it relies only on `globalThis.fetch` (or an injected `FetchLike`) and the
slim-core `httpRequest()` helper, so it runs unchanged in Node, Bun, browsers,
and Web Workers.
## Features
- Implements `CurrencyProvider` with `name === "open-exchange-rates"`,
`supportsFiat === true`, `supportsCrypto === false`.
- Constructor takes the OXR `app_id` as `apiKey`. Empty string raises
`CurrencyProviderError(MISSING_CONFIG)` synchronously.
- Free-tier safe by default: any `RateFetchOptions.base !== "USD"` raises
`CurrencyProviderError(UNSUPPORTED_BASE)` before any network call. Paid-plan
callers opt in with `allowNonUsdBase: true`.
- Routes every request through `@suaveplan/currency-providers` `httpRequest()`
so non-2xx responses surface `CurrencyProviderError(HTTP_ERROR)` with
`status` and `providerName` populated. Caller `AbortSignal` from
`RateFetchOptions.signal` is forwarded.
- Validates the upstream `{ base, rates, timestamp }` shape. Mismatches raise
`CurrencyProviderError(PARSE_ERROR)`.
- Detects the OXR 2xx error-payload shape (`{ error: true, status, message }`)
and raises `CurrencyProviderError(UPSTREAM_ERROR)` with `upstreamMessage`
carrying the upstream `message` field.
- Filters the rates map to `RateFetchOptions.targets`. Missing target codes
raise `CurrencyProviderError(UNKNOWN_CURRENCY)`.
- Converts the OXR Unix-seconds timestamp to milliseconds.
- Injectable `FetchLike` for offline testing; defaults to `globalThis.fetch`.
## Usage: Basic Example
```typescript
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
const provider = new OpenExchangeRatesProvider({
apiKey: process.env.OXR_APP_ID ?? "",
});
const result = await provider.fetchRates({ base: "USD" });
console.log(result.base, result.rates.EUR, result.timestamp);
```
## Usage: Advanced Example
Wiring the provider into `CurrencyConverter` with a paid-tier app_id, custom
`baseUrl` for staging, and `AbortSignal`-driven cancellation:
```typescript
import { CurrencyConverter } from "@suaveplan/currency";
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
const oxr = new OpenExchangeRatesProvider({
apiKey: process.env.OXR_APP_ID!,
allowNonUsdBase: true,
baseUrl: process.env.OXR_BASE_URL ?? "https://openexchangerates.org/api",
});
const converter = new CurrencyConverter({
provider: oxr,
staleDurationMs: 30 * 60 * 1000, // 30 minutes
});
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("timeout")), 5000);
const rates = await oxr.fetchRates({
base: "EUR",
targets: ["USD", "GBP", "JPY"],
signal: controller.signal,
});
```
The slim-core `CurrencyProviderRegistry` is also supported when the consumer
wires multiple providers behind a single lookup surface — see
`@suaveplan/currency-providers` README.
## API Reference
### `OpenExchangeRatesProviderConfig`
Extends `ApiKeyConfig` from `@suaveplan/currency-providers`:
- `apiKey: string` — required. The OXR `app_id`.
- `baseUrl?: string` — defaults to `"https://openexchangerates.org/api"`.
- `fetch?: FetchLike` — defaults to `globalThis.fetch`.
- `allowNonUsdBase?: boolean` — defaults to `false`. When `false`, any
non-USD base raises `UNSUPPORTED_BASE` before any network call.
### `class OpenExchangeRatesProvider`
#### Properties
- `readonly name: "open-exchange-rates"`
- `readonly supportsFiat: true`
- `readonly supportsCrypto: false`
#### `constructor(config: OpenExchangeRatesProviderConfig)`
Throws `CurrencyProviderError(MISSING_CONFIG)` when `config.apiKey === ""`.
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
Issues `GET ${baseUrl}/latest.json?app_id=<apiKey>&base=<base>` via
`httpRequest()`. Returns `{ base, rates, timestamp }` with `timestamp` in
milliseconds.
Throws:
- `MISSING_CONFIG` — from the constructor when `apiKey === ""`.
- `UNSUPPORTED_BASE` — when `options.base !== "USD"` and `allowNonUsdBase`
is not set to `true`.
- `HTTP_ERROR` — via `httpRequest()` for any non-2xx response.
- `PARSE_ERROR` — when the response body is not JSON, not an object, or is
missing one of `base` / `rates` / `timestamp` in the expected shape.
- `UPSTREAM_ERROR` — when the response is 2xx but the body is the OXR error
shape (`{ error: true, status, message }`).
- `UNKNOWN_CURRENCY` — when `options.targets` contains a code not present in
the upstream `rates` map.
## Implementation Notes
- The OXR free tier restricts the base currency to USD; the provider enforces
this client-side so the failure surfaces as a typed `UNSUPPORTED_BASE`
rather than a generic upstream error. Paid plans pass `allowNonUsdBase:
true` to opt out.
- OXR returns `timestamp` in Unix seconds; the provider multiplies by 1000 so
every `CurrencyProvider` answer is in milliseconds, matching
`RateFetchResult.timestamp`'s contract.
- OXR's error-payload shape is returned with a 2xx HTTP status, which would
otherwise pass `httpRequest()`'s non-2xx check silently. The provider
detects `{ error: true, … }` after parsing and surfaces `UPSTREAM_ERROR`
with `status` and `upstreamMessage` populated.
- The provider does not retry, dedupe, or rate-limit by itself — the slim
core's `## Out of scope` defers those concerns to a cross-cutting follow-up
wave so every sibling provider gains them in lockstep.
- `apiKey` is `encodeURIComponent`-wrapped in the query string to prevent
injection when callers pass keys containing reserved characters.

View File

@@ -0,0 +1,92 @@
# @suaveplan/currency-provider-virtual - Universal in-memory virtual / game currency provider for @suaveplan/currency. Programmable rate table (setRate / removeRate / clear) with optional Clock injection for deterministic timestamps. No HTTP, no API key, no FetchLike. Throws typed CurrencyProviderError(UNKNOWN_CURRENCY) when the requested base is absent. Sibling of the @suaveplan/currency-providers slim core.
In-memory, programmable virtual / game currency provider for
`@suaveplan/currency`. Sibling of the `@suaveplan/currency-providers`
slim plugin core. No HTTP, no API key, no `FetchLike` — purely an
operator-controlled rate table backed by a nested `Map`.
Use it for game economies, loyalty points, internal test rates, or any
rate source you want to seed and mutate deterministically in process.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — This module surfaces the single `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code that `@suaveplan/currency-provider-virtual` r…
- [error-codes](./src/error-codes/error-codes.md) — This module surfaces the single `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code that `@suaveplan/currency-provider-virtual` r…
- [provider](./src/provider/provider.md) — `VirtualCurrencyProvider` is an in-memory, programmable rate-source implementation of `@suaveplan/currency`'s `Currency…
- [provider](./src/provider/provider.md) — `VirtualCurrencyProvider` is an in-memory, programmable rate-source implementation of `@suaveplan/currency`'s `Currency…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-virtual @suaveplan/currency-providers @suaveplan/currency
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
const virtual = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [
{ from: "USD", to: "GOLD", rate: 100 },
{ from: "USD", to: "GEMS", rate: 500 },
],
});
const registry = new CurrencyProviderRegistry();
registry.register(virtual);
const converter = new CurrencyConverter({ provider: registry.get("virtual-currency") });
const rate = await converter.getRate("USD", "GOLD"); // 100
// Operators can mutate at any time.
virtual.setRate("USD", "GOLD", 110);
virtual.removeRate("USD", "GEMS");
```
## Features
- Programmable rate table: `setRate(from, to, rate)` (upsert),
`removeRate(from, to)` (idempotent), `clear()`,
`hasRate(from, to)`, `getRate(from, to)`,
`listCurrencies(): readonly string[]` (sorted snapshot).
- `fetchRates(options?)` returns directed rates stored under the
requested `base`, optionally filtered by `options.targets`.
- Typed `CurrencyProviderError(UNKNOWN_CURRENCY)` raised when the
requested base has no rates stored (no triangulation; the table can
be empty).
- Optional `Clock` injection (`@suaveplan/types`) for deterministic
`RateFetchResult.timestamp` values under `FakeClock`-driven tests.
- Implements the `CurrencyProvider` contract from `@suaveplan/currency`
with `supportsVirtual: true`, `supportsFiat: false`,
`supportsCrypto: false`, `name: "virtual-currency"`.
- Universal — runs unchanged on Node 18+, Bun, browsers, and Web
Workers. Zero browser/Node/DOM APIs in production source.
- `sideEffects: false` — the constructor never mutates global state
(no `@suaveplan/money` `registerCurrency` integration).
- Explicit named barrels; two subpath exports; 100/100/100/100
istanbul coverage.
## Modules
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`VIRTUAL_CURRENCY_PROVIDER_CODES`, `VIRTUAL_PROVIDER_NAME`, and the
`makeUnknownCurrencyError(base)` helper.
- [`src/provider/provider.md`](./src/provider/provider.md) —
`VirtualCurrencyProvider` class + `VirtualCurrencyProviderConfig` and
`VirtualRateEntry` types.
## Status
`0.1.0` (pre-graduation). Sibling of the
`@suaveplan/currency-providers` slim core landed on 2026-05-17. See
the design proposal
for the migration record. The cross-cutting architecture for the
`@suaveplan/currency-provider-*` family is documented in
the design document.

View File

@@ -0,0 +1,104 @@
# error-codes — typed error helpers for the virtual provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module surfaces the single `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code
that `@suaveplan/currency-provider-virtual` raises, plus a tiny
`makeUnknownCurrencyError(base)` builder that constructs the typed
`CurrencyProviderError` with the canonical message format and
`providerName: "virtual-currency"` discriminant pre-populated. The full
8-code registry and the `CurrencyProviderError` class itself live in the
`@suaveplan/currency-providers` slim core — this module never adds new
codes, it only re-narrows the surface the virtual provider actually uses.
The `VIRTUAL_PROVIDER_NAME` constant is the stable identifier exposed as
`provider.name` and stamped onto every emitted error's `providerName`
discriminant, so consumer assertions can pin to a single source of truth.
## Features
- `VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` — re-export of the
slim-core `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code (string-identical).
- `VIRTUAL_PROVIDER_NAME` — `"virtual-currency"`.
- `makeUnknownCurrencyError(base)` — returns a `CurrencyProviderError`
with the canonical message and `providerName` discriminant set.
## Usage: Basic
```ts
import {
makeUnknownCurrencyError,
VIRTUAL_CURRENCY_PROVIDER_CODES,
} from "@suaveplan/currency-provider-virtual/error-codes/error-codes";
const error = makeUnknownCurrencyError("NOPE");
console.log(error.code); // "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"
console.log(error.providerName); // "virtual-currency"
```
## Usage: Advanced
```ts
import { CurrencyProviderError } from "@suaveplan/currency-providers";
import {
makeUnknownCurrencyError,
VIRTUAL_CURRENCY_PROVIDER_CODES,
} from "@suaveplan/currency-provider-virtual/error-codes/error-codes";
function isUnknownVirtualCurrency(err: unknown): err is CurrencyProviderError {
return (
err instanceof CurrencyProviderError &&
err.code === VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY &&
err.providerName === "virtual-currency"
);
}
try {
throw makeUnknownCurrencyError("GHOST");
} catch (err) {
if (isUnknownVirtualCurrency(err)) {
// Fall back to the next provider in the chain.
} else {
throw err;
}
}
```
## API Reference
### `VIRTUAL_CURRENCY_PROVIDER_CODES`
Frozen record. Contains exactly one key — `UNKNOWN_CURRENCY` — whose
value is the namespaced string `"CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`
imported from the slim core. Reference-equal with the slim-core
constant; comparisons via `===` work as expected.
### `VIRTUAL_PROVIDER_NAME`
`"virtual-currency"`. Stable identifier used as `provider.name` and as
the `providerName` discriminant on every emitted error.
### `makeUnknownCurrencyError(base: string): CurrencyProviderError`
Builds the canonical error for a missing-base lookup.
- `base` — the unknown base currency code; included verbatim in the
message.
- Returns a `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`,
`providerName === "virtual-currency"`, and message format
`"VirtualCurrencyProvider: no rates stored for base \"<base>\""`.
## Implementation Notes
- This module owns zero codes — it re-exports one from the slim core.
Adding a new code requires an OpenSpec change against
`@suaveplan/currency-providers` (which holds the canonical registry),
not against this package.
- The helper is `const`-only — no top-level state, no I/O, no closures
captured — so it remains tree-shake-friendly under
`"sideEffects": false`.

View File

@@ -0,0 +1,160 @@
# provider — VirtualCurrencyProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VirtualCurrencyProvider` is an in-memory, programmable rate-source
implementation of `@suaveplan/currency`'s `CurrencyProvider` contract.
It serves operator-defined virtual / game currencies (and any internal
test rates) without ever issuing an HTTP request or consuming a
`FetchLike`. Rates are stored as a nested `Map<from, Map<to, number>>`
and mutated at runtime through `setRate`, `removeRate`, and `clear`.
The provider sets `supportsVirtual: true` and is the only sibling in
the `@suaveplan/currency-provider-*` family that does not depend on the
slim core's `httpRequest` helper. Use it for game economies, loyalty
points, internal test fixtures, or any rate table you want to control
deterministically.
## Features
- `setRate(from, to, rate)` — upsert a directed rate; replaces an
existing pair without throwing.
- `removeRate(from, to): boolean` — idempotent removal; returns whether
anything was actually removed. Auto-cleans the inner map when the
last rate for a `from` is dropped.
- `clear()` — empty the table in one call.
- `hasRate(from, to): boolean`, `getRate(from, to): number | undefined`,
`listCurrencies(): readonly string[]` — read-only helpers.
- `fetchRates(options?)` — returns the rates stored under
`options.base` (default `defaultBase`), optionally filtered by an
`options.targets` inclusion list. Throws
`CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` when the
requested base is not in the table.
- Optional `Clock` injection for deterministic `timestamp` values.
- Universal — zero browser / Node / DOM APIs, `sideEffects: false`.
## Usage: Basic
```ts
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [
{ from: "USD", to: "GOLD", rate: 100 },
{ from: "USD", to: "GEMS", rate: 500 },
],
});
const result = await provider.fetchRates();
// { base: "USD", rates: { GOLD: 100, GEMS: 500 }, timestamp: <Date.now()> }
```
## Usage: Advanced
```ts
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
import { CURRENCY_PROVIDER_CODES, CurrencyProviderError } from "@suaveplan/currency-providers";
// Deterministic timestamps + partial-graph rates (no triangulation).
const fakeClock = new FakeClock({ startTime: 1_700_000_000_000 });
const provider = new VirtualCurrencyProvider({
defaultBase: "GOLD",
clock: () => fakeClock.now(),
});
provider.setRate("GOLD", "GEMS", 5);
provider.setRate("GOLD", "USD", 0.01);
const golden = await provider.fetchRates({ base: "GOLD", targets: ["GEMS"] });
// { base: "GOLD", rates: { GEMS: 5 }, timestamp: 1_700_000_000_000 }
fakeClock.advance(1000);
const stale = await provider.fetchRates({ base: "GOLD" });
// { base: "GOLD", rates: { GEMS: 5, USD: 0.01 }, timestamp: 1_700_000_001_000 }
// Operator updates a rate at runtime — next fetchRates picks it up.
provider.setRate("GOLD", "GEMS", 6);
// Unknown base raises the typed error.
try {
await provider.fetchRates({ base: "GHOST" });
} catch (err) {
if (
err instanceof CurrencyProviderError &&
err.code === CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
) {
// expected
}
}
```
## API Reference
### `class VirtualCurrencyProvider implements CurrencyProvider`
Constructor: `new VirtualCurrencyProvider(config?: VirtualCurrencyProviderConfig)`.
Static identity:
- `name === "virtual-currency"`
- `supportsCrypto === false`
- `supportsFiat === false`
- `supportsVirtual === true`
Methods:
- `setRate(from: string, to: string, rate: number): void` — upsert. No
throw on duplicate pair.
- `removeRate(from: string, to: string): boolean` — idempotent. Never
throws.
- `clear(): void` — empties the table.
- `hasRate(from: string, to: string): boolean`
- `getRate(from: string, to: string): number | undefined`
- `listCurrencies(): readonly string[]` — sorted snapshot of every
`from` key.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
rejects with `CurrencyProviderError(UNKNOWN_CURRENCY)` when the
requested base is not stored; otherwise resolves to the rate map for
that base, filtered by `options.targets` when provided, with
`timestamp = clock()`.
### `interface VirtualCurrencyProviderConfig`
- `defaultBase?: string` — base used when `fetchRates()` omits
`options.base`. Defaults to `"USD"`.
- `initialRates?: readonly VirtualRateEntry[]` — entries upserted at
construction. Duplicates within the array are last-write-wins.
- `clock?: Clock` — `() => number` (ms since epoch). Defaults to
`() => Date.now()`.
### `interface VirtualRateEntry`
`{ from: string; to: string; rate: number }`.
## Implementation Notes
- The rate table is `Map<from, Map<to, number>>` — O(1) lookup,
O(n) enumeration over the inner map. The flat alternative
(`Map<"${from}->${to}", number>`) was rejected because currency codes
can contain `-` (e.g. `USDC-GOLD`, `GAME-1-COIN`) which would create
ambiguous keys.
- `removeRate` deletes the empty inner map after the last pair under a
`from` is removed, so `listCurrencies()` stays accurate without a
separate compaction pass.
- The `fetchRates` "no I/O" promise wrapping satisfies the
`CurrencyProvider` async contract without introducing a microtask
delay observable to callers; the work is purely synchronous.
- `Clock` is invoked once per `fetchRates` call, at the moment of the
call (not construction). This matches the wall-clock semantics
every other provider in the family uses.
- The provider is intentionally side-effect-free in its constructor
(unlike the upstream form which called `@suaveplan/money`
`registerCurrency`) — keeping `"sideEffects": false` honest and
tree-shake safe.

View File

@@ -0,0 +1,126 @@
# @suaveplan/currency-providers - Universal slim core for currency providers: CurrencyProviderRegistry, defineCurrencyProvider factory, shared httpRequest/composeAbortSignal helper, typed CurrencyProviderError + 8-code registry, and the ApiKeyConfig / NoKeyConfig / HttpFetchOptions / ProviderConfigBase shared types. Concrete providers (ECB, Fixer, Open Exchange Rates, Binance, CoinGecko, CoinMarketCap, CryptoCompare, Kraken, Virtual) ship as sibling @suaveplan/currency-provider-<name> packages.
Slim plugin core for `@suaveplan/currency` rate providers. Ships the
`CurrencyProviderRegistry` (name-keyed lookup), the
`defineCurrencyProvider` factory, the shared `httpRequest` /
`composeAbortSignal` helper, the typed `CurrencyProviderError` with the
8-code `CURRENCY_PROVIDER_*` registry, and the four shared config types
(`ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`, `ProviderConfigBase`)
that every sibling provider extends.
The concrete provider classes (ECB, Fixer, Open Exchange Rates, Binance,
CoinGecko, CoinMarketCap, CryptoCompare, Kraken, Virtual) live in
sibling `@suaveplan/currency-provider-<name>` packages — install only the
ones you wire in.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CURRENCY_PROVIDER_CODES` registry and the `CurrencyProviderError` class every provider in `@suavepl…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CURRENCY_PROVIDER_CODES` registry and the `CurrencyProviderError` class every provider in `@suavepl…
- [factory](./src/factory/factory.md) — Exposes `defineCurrencyProvider`, the low-ceremony factory that sibling `@suaveplan/currency-provider-<name>` packages…
- [factory](./src/factory/factory.md) — Exposes `defineCurrencyProvider`, the low-ceremony factory that sibling `@suaveplan/currency-provider-<name>` packages…
- [http](./src/http/http.md) — Exposes the two HTTP utilities every sibling `@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an a…
- [http](./src/http/http.md) — Exposes the two HTTP utilities every sibling `@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an a…
- [registry](./src/registry/registry.md) — Defines `CurrencyProviderRegistry`, the in-memory registry that sibling `@suaveplan/currency-provider-<name>` packages…
- [registry](./src/registry/registry.md) — Defines `CurrencyProviderRegistry`, the in-memory registry that sibling `@suaveplan/currency-provider-<name>` packages…
- [types](./src/types/configs.md) — Defines the three configuration interfaces every concrete provider in `@suaveplan/currency-providers` consumes: `ApiKey…
- [configs](./src/types/configs.md) — Defines the three configuration interfaces every concrete provider in `@suaveplan/currency-providers` consumes: `ApiKey…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-providers @suaveplan/currency
# plus whichever sibling provider packages you need:
bun add @suaveplan/currency-provider-ecb @suaveplan/currency-provider-coingecko
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
registry.register(new CoinGeckoProvider());
const converter = new CurrencyConverter({
provider: registry.get("ecb"),
fallbackProviders: [registry.get("coingecko")],
});
const rate = await converter.getRate("USD", "EUR");
```
Direct instantiation without the registry is equally supported — the
registry is a convenience for callers that wire many providers and want a
single lookup surface. Sibling packages remain `CurrencyProvider`-compatible
so `new CurrencyConverter({ provider: new EcbProvider() })` continues to work.
## Features
- `CurrencyProviderRegistry` — register / get / has / list / unregister.
Typed `PROVIDER_NOT_REGISTERED` / `PROVIDER_ALREADY_REGISTERED` errors;
`list()` returns a sorted snapshot for deterministic downstream tests
- `defineCurrencyProvider` — factory for sibling packages that prefer the
object-literal idiom over a hand-rolled class. Returns an
`Object.freeze`-d `CurrencyProvider`
- `httpRequest` — shared HTTP helper used by every sibling provider:
abort-signal composition, timeout derivation, non-2xx → `HTTP_ERROR`
mapping, returns the raw `Response`
- `composeAbortSignal` — pure four-way composition of caller + timeout
`AbortSignal`s (`undefined`, single, single, `AbortSignal.any`)
- Typed `CurrencyProviderError` extending `SuaveplanError` with 8 stable
codes (`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNSUPPORTED_BASE`,
`MISSING_CONFIG`, `UNKNOWN_CURRENCY`, `PROVIDER_NOT_REGISTERED`,
`PROVIDER_ALREADY_REGISTERED`)
- Shared config types — `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`,
`ProviderConfigBase` with optional injectable `FetchLike`
- Universal — zero browser/Node APIs, runs unchanged under Node ≥ 18,
Bun, modern browsers, and Web Workers
- Five subpath exports; explicit named barrels; 100/100/100/100 istanbul
coverage
## Modules
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`CURRENCY_PROVIDER_CODES` registry + `CurrencyProviderError` class
- [`src/types/configs.md`](./src/types/configs.md) — `ApiKeyConfig`,
`NoKeyConfig`, `HttpFetchOptions`, `ProviderConfigBase`
- [`src/registry/registry.md`](./src/registry/registry.md) —
`CurrencyProviderRegistry`
- [`src/factory/factory.md`](./src/factory/factory.md) —
`defineCurrencyProvider` factory + `DefineCurrencyProviderInput`
- [`src/http/http.md`](./src/http/http.md) — `httpRequest` +
`composeAbortSignal` shared HTTP helpers
## Sibling provider packages
| Provider | Sibling package |
|--------------------------|-------------------------------------------------------|
| European Central Bank | `@suaveplan/currency-provider-ecb` |
| Fixer.io | `@suaveplan/currency-provider-fixer` |
| Open Exchange Rates | `@suaveplan/currency-provider-open-exchange-rates` |
| Binance | `@suaveplan/currency-provider-binance` |
| CoinGecko | `@suaveplan/currency-provider-coingecko` |
| CoinMarketCap | `@suaveplan/currency-provider-coinmarketcap` |
| CryptoCompare | `@suaveplan/currency-provider-cryptocompare` |
| Kraken | `@suaveplan/currency-provider-kraken` |
| Virtual (game / loyalty) | `@suaveplan/currency-provider-virtual` |
Each sibling depends on this slim core for the registry, factory, HTTP
helper, error class, and config types — and re-implements only its own
provider-specific logic.
## Status
`0.1.0` (pre-graduation). Slim refactor of the 2026-05-17 migration:
provider classes moved out to sibling packages; the registry / factory /
HTTP helper landed here. See
the design proposal for the
refactor record and migration notes.

View File

@@ -0,0 +1,110 @@
# `error-codes` — Typed error registry for currency providers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `CURRENCY_PROVIDER_CODES` registry and the
`CurrencyProviderError` class every provider in
`@suaveplan/currency-providers` raises. The registry guarantees that
downstream code — most importantly `CurrencyConverter`'s fallback chain —
can programmatically dispatch on the namespaced code string instead of
parsing free-form error messages. This is the suaveplan-quality replacement
for the upstream package's plain `throw new Error(...)` pattern, which
gave fallback logic no way to tell "Fixer returned 401 — try the next
provider" apart from "Fixer URL was malformed — give up".
`CurrencyProviderError` extends `SuaveplanError<CurrencyProviderErrorCode>`,
preserving the full `SuaveplanError` contract — `cause` chaining,
`toWire()` redaction, OTel span enrichment, and i18n-key dispatch — while
adding three optional discriminants relevant to provider failures:
`providerName` (which provider hit the error), `status` (the HTTP status
when the failure came from an HTTP response), and `upstreamMessage` (the
free-form error string the upstream API supplied).
## Features
- Eight stable error codes covering every provider failure mode plus the
slim-core registry dispatch: HTTP_ERROR, PARSE_ERROR, UPSTREAM_ERROR,
UNSUPPORTED_BASE, MISSING_CONFIG, UNKNOWN_CURRENCY,
PROVIDER_NOT_REGISTERED, PROVIDER_ALREADY_REGISTERED
- `CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>` —
full `SuaveplanError` contract preserved
- Optional `providerName` / `status` / `upstreamMessage` discriminants
- Registered via `defineCodes` so the code values are typed as the
namespaced strings (`"CURRENCY_PROVIDER.HTTP_ERROR"` etc.)
- The first six codes are reachable from sibling
`@suaveplan/currency-provider-<name>` packages (where the concrete HTTP
call sites live); the two `PROVIDER_*` codes are reachable from this
package's `CurrencyProviderRegistry`
## Usage: Basic Example
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.HTTP_ERROR,
"ECB API error: 503 Service Unavailable",
{ providerName: "ecb", status: 503 }
);
```
## Usage: Advanced Example
Use the codes to drive fallback-chain decisions in a custom converter:
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
async function fetchWithFallback(providers: readonly CurrencyProvider[]) {
let lastError: unknown;
for (const provider of providers) {
try {
return await provider.fetchRates();
} catch (error) {
lastError = error;
if (!(error instanceof CurrencyProviderError)) throw error;
// 4xx auth / config errors are not worth retrying with the next
// provider — re-throw to bubble up
if (error.code === CURRENCY_PROVIDER_CODES.MISSING_CONFIG) throw error;
if (error.status === 401 || error.status === 403) throw error;
// anything else: try next provider
}
}
throw lastError;
}
```
## API Reference
- `const CURRENCY_PROVIDER_CODES` — frozen registry mapping each code name
to its namespaced string (`"CURRENCY_PROVIDER.HTTP_ERROR"`, etc.).
- `type CurrencyProviderErrorCode` — union of the eight namespaced strings.
- `interface CurrencyProviderErrorOptions extends SuaveplanErrorOptions` —
adds optional `providerName: string`, `status: number`,
`upstreamMessage: string`.
- `class CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>`
— constructor `(code, message, options?)`. Read-only `providerName`,
`status`, `upstreamMessage` properties.
## Implementation Notes
- The discriminants are surfaced as top-level read-only properties (rather
than fields on `context`) to make destructured `try/catch` handling
ergonomic — `if (error instanceof CurrencyProviderError && error.status === 429)`
reads naturally.
- Codes that don't have a natural HTTP shape (e.g. `MISSING_CONFIG`,
`UNKNOWN_CURRENCY`) simply omit `status` and `upstreamMessage`. The
optional fields are populated only when meaningful.
- `defineCodes` enforces namespace prefix at runtime — `CURRENCY_PROVIDER.X`
cannot collide with `CURRENCY.X` from `@suaveplan/currency` or any other
package's registry.

View File

@@ -0,0 +1,132 @@
# `factory` — defineCurrencyProvider factory helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes `defineCurrencyProvider`, the low-ceremony factory that sibling
`@suaveplan/currency-provider-<name>` packages use to declare a provider as
a plain object literal instead of a hand-rolled class. The factory accepts a
`DefineCurrencyProviderInput` (the static identity flags plus a `fetchRates`
function) and returns an `Object.freeze`-d `CurrencyProvider` that
`CurrencyProviderRegistry` and `CurrencyConverter` accept identically to a
class-based provider.
Two idioms are equally supported across the ecosystem — `class
EcbProvider implements CurrencyProvider` for sibling packages that prefer
the OO shape, and `defineCurrencyProvider({ ... })` for packages that prefer
the functional shape. Neither is privileged; the factory exists so sibling
authors do not need to write the `name` / `supportsCrypto` / `supportsFiat`
boilerplate by hand when a closure over a config object is the natural
expression.
## Features
- Returns an `Object.frozen` `CurrencyProvider` — identity stable, no
accidental mutation
- Forwards every `RateFetchOptions` field unchanged to the supplied
`fetchRates` function
- Preserves the optional `supportsVirtual` flag only when explicitly
provided (matches the `CurrencyProvider` contract from
`@suaveplan/currency`)
- Named `DefineCurrencyProviderInput` interface (no inline object literals
in the factory signature) — satisfies AGENTS.md §11
## Usage: Basic Example
```ts
import { defineCurrencyProvider } from "@suaveplan/currency-providers";
const provider = defineCurrencyProvider({
name: "static-eur",
supportsCrypto: false,
supportsFiat: true,
async fetchRates() {
return {
base: "EUR",
rates: { USD: 1.08, GBP: 0.86 },
timestamp: Date.now(),
};
},
});
console.log(provider.name); // "static-eur"
```
## Usage: Advanced Example
Compose the factory with the shared `httpRequest` helper to author a real
provider with three lines of HTTP wiring:
```ts
import {
defineCurrencyProvider,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
const DEFAULT_URL = "https://api.example.com/v1/rates";
export function createExampleProvider(config?: NoKeyConfig) {
const baseUrl = config?.baseUrl ?? DEFAULT_URL;
const fetchImpl = config?.fetch ?? globalThis.fetch;
return defineCurrencyProvider({
name: "example",
supportsCrypto: false,
supportsFiat: true,
async fetchRates(options) {
const response = await httpRequest({
url: baseUrl,
fetch: fetchImpl,
providerName: "example",
signal: options?.signal,
timeoutMs: 10_000,
});
const payload = (await response.json()) as {
base: string;
rates: Record<string, number>;
timestamp: number;
};
return {
base: payload.base,
rates: payload.rates,
timestamp: payload.timestamp * 1000,
};
},
});
}
```
## API Reference
- `interface DefineCurrencyProviderInput` — input contract.
- `name: string` — stable identifier surfaced as `provider.name`.
- `supportsCrypto: boolean`
- `supportsFiat: boolean`
- `supportsVirtual?: boolean` — optional. When omitted, the returned
provider does not declare the property at all (matches the
`CurrencyProvider` contract).
- `fetchRates: (options?: RateFetchOptions) => Promise<RateFetchResult>`
— the user-supplied implementation. Called directly; the factory
inserts no wrapping behaviour.
- `function defineCurrencyProvider(input: DefineCurrencyProviderInput): CurrencyProvider`
— returns an `Object.freeze`-d `CurrencyProvider`. Identity-stable; safe
to compare with `===`.
## Implementation Notes
- The factory deliberately does **not** apply HTTP error mapping, abort
composition, or schema validation — those concerns live in the
`httpRequest` helper and in each sibling provider's own implementation.
`defineCurrencyProvider` is a thin shape adapter, not a framework.
- `Object.freeze` is shallow — the `name` / `supportsCrypto` /
`supportsFiat` / `supportsVirtual` properties are immutable, but the
closure variables that `fetchRates` captures are owned by the caller and
remain mutable if the caller chose to make them so.
- The `supportsVirtual` field is conditionally included so that providers
that do not deal with virtual currencies surface `undefined` via
`provider.supportsVirtual === undefined` (the contract's "optional"
semantics), rather than always being literally present as `false`.

View File

@@ -0,0 +1,155 @@
# `http` — Shared HTTP helper for sibling provider packages
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the two HTTP utilities every sibling
`@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an
async request through the injected `FetchLike` with typed-error mapping and
abort-signal composition) and `composeAbortSignal` (a pure helper that
merges a caller `AbortSignal` with an optional timeout-derived one).
The previous shape of `@suaveplan/currency-providers` had every provider
re-implement the same `await this.fetchImpl(url); if (!response.ok) throw
new CurrencyProviderError(HTTP_ERROR, ...)` block by hand. That repetition
is exactly the signal that the helper belongs in the slim core: every
sibling now `import { httpRequest } from "@suaveplan/currency-providers"`
and the HTTP_ERROR call site lives in one place, exercised by one test
file.
## Features
- `httpRequest` — single async helper covering the full HTTP lifecycle:
abort-signal composition, timeout derivation, fetch invocation, non-2xx
→ `CurrencyProviderError(HTTP_ERROR)` mapping
- `composeAbortSignal` — pure four-way composition: `undefined`, caller
only, timeout only, both (via `AbortSignal.any`)
- Returns the raw `Response` so callers handle `.json()` / `.text()` /
`.arrayBuffer()` according to their own payload shape — no premature
body-format assumption
- Universal — uses only WHATWG `fetch`, `AbortSignal.timeout`, and
`AbortSignal.any` (all available in Node 20+, Bun, and modern browsers)
- Typed error carries `providerName` and `status` discriminants so audit
log handlers can dispatch without parsing the message string
## Usage: Basic Example
```ts
import { httpRequest } from "@suaveplan/currency-providers";
const response = await httpRequest({
url: "https://api.example.com/v1/rates",
fetch: globalThis.fetch,
providerName: "example",
});
const payload = await response.json();
```
## Usage: Advanced Example
Wire `httpRequest` into a sibling provider with both a timeout and the
caller's `RateFetchOptions.signal`, then handle `HTTP_ERROR` against the
typed code rather than the message:
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { RateFetchOptions, RateFetchResult } from "@suaveplan/currency";
const DEFAULT_URL = "https://api.example.com/v1/rates";
const NAME = "example";
export class ExampleProvider {
public readonly name = NAME;
public readonly supportsCrypto = false;
public readonly supportsFiat = true;
private readonly baseUrl: string;
private readonly fetchImpl;
constructor(config?: NoKeyConfig) {
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
this.fetchImpl = config?.fetch ?? globalThis.fetch;
}
async fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
try {
const response = await httpRequest({
url: this.baseUrl,
fetch: this.fetchImpl,
providerName: NAME,
signal: options?.signal,
timeoutMs: 10_000,
});
const payload = (await response.json()) as {
base: string;
rates: Record<string, number>;
timestamp: number;
};
return {
base: payload.base,
rates: payload.rates,
timestamp: payload.timestamp * 1000,
};
} catch (error) {
if (
error instanceof CurrencyProviderError &&
error.code === CURRENCY_PROVIDER_CODES.HTTP_ERROR
) {
// optionally: backoff, fallback, audit-log etc.
}
throw error;
}
}
}
```
## API Reference
- `interface HttpRequestInput`
- `url: string` — fully-resolved request URL (query string already
appended at the call site).
- `fetch: FetchLike` — injected `FetchLike` (the sibling provider
defaults to `globalThis.fetch` at construction time).
- `providerName: string` — carried on the raised
`CurrencyProviderError.providerName`.
- `init?: RequestInit` — optional WHATWG `RequestInit` (method, headers,
body, etc.). The helper preserves caller-supplied `headers` and
`method`; it only inserts `signal` when an abort signal needs to be
attached.
- `signal?: AbortSignal` — caller's abort signal (usually from
`RateFetchOptions.signal`).
- `timeoutMs?: number` — when set, the helper derives
`AbortSignal.timeout(timeoutMs)` and composes it with the caller signal.
- `function httpRequest(input: HttpRequestInput): Promise<Response>` —
resolves to the raw `Response` on 2xx, rejects with
`CurrencyProviderError(HTTP_ERROR)` on non-2xx.
- `function composeAbortSignal(caller, timeout): AbortSignal | undefined`
— pure four-way composition. Returns the single input verbatim when only
one is provided (no wrapping cost). Returns
`AbortSignal.any([caller, timeout])` when both are present.
## Implementation Notes
- The helper does **not** apply retry, backoff, or rate-limit semantics —
those wrappers compose at a higher layer (see the deferred surfaces in
the 2026-05-17 archive's `## Out of scope`).
- `AbortSignal.timeout` was chosen over a manual `setTimeout +
AbortController` to keep the helper universal (no `setTimeout`
scheduling owned by the helper) and to avoid the cleanup foot-gun of a
pending timer outliving a fast-success request.
- `composeAbortSignal` deliberately returns the **single input** unchanged
when only one is provided. Wrapping a single signal through
`AbortSignal.any([s])` would still produce a valid signal but would
break identity-comparison invariants downstream consumers might rely on.
- The non-2xx error message intentionally uses `response.statusText` (the
HTTP reason phrase) so the human-readable form survives audit log
redaction; programmatic dispatch should use `error.code` and
`error.status` instead.

View File

@@ -0,0 +1,128 @@
# `registry` — Name-keyed lookup of CurrencyProvider instances
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `CurrencyProviderRegistry`, the in-memory registry that sibling
`@suaveplan/currency-provider-<name>` packages register their providers
with and that consumers (the `CurrencyConverter` wiring, future
`AggregatorProvider` / `RateLimitedProvider` wrappers, admin UIs that toggle
providers on and off) use to resolve providers by name without tracking
concrete classes themselves.
The registry is **opt-in convenience**, not a required indirection:
`new CurrencyConverter({ provider: new EcbProvider() })` continues to work
without a registry. The registry exists for callers that wire many providers
from configuration, that need a single canonical instance of each provider,
or that want a uniform "look up by name" surface to feed into wrappers.
## Features
- Five-method API — `register`, `get`, `has`, `list`, `unregister`
- Typed `CurrencyProviderError` on every failure mode (no plain `Error`s)
- `register` throws `PROVIDER_ALREADY_REGISTERED` on duplicate names —
explicit `unregister` + `register` required to replace
- `get` throws `PROVIDER_NOT_REGISTERED` on unknown names; `has` is a
non-throwing probe for the same condition
- `list()` returns a **sorted snapshot** so equal sets of providers
produce identical listings regardless of insertion order — keeps
downstream golden-file tests deterministic
- `unregister` is idempotent — returns `true` when something was removed,
`false` otherwise, never throws
- Providers stored by reference (no cloning / freezing / defensive copy)
## Usage: Basic Example
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
registry.register(new CoinGeckoProvider());
const converter = new CurrencyConverter({
provider: registry.get("ecb"),
fallbackProviders: [registry.get("coingecko")],
});
```
## Usage: Advanced Example
Wire providers from a config file, swap them at runtime, and enumerate them
deterministically for a status dashboard:
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import type { CurrencyProvider } from "@suaveplan/currency";
interface ProviderModule {
readonly create: () => CurrencyProvider;
}
function wire(
modules: Readonly<Record<string, ProviderModule>>,
enabled: readonly string[]
): CurrencyProviderRegistry {
const registry = new CurrencyProviderRegistry();
for (const key of enabled) {
const mod = modules[key];
if (mod !== undefined) registry.register(mod.create());
}
return registry;
}
// Swap "ecb" for a different ECB instance with a custom fetch
const registry = wire(modules, ["ecb", "coingecko", "kraken"]);
registry.unregister("ecb");
registry.register(new EcbProvider({ fetch: instrumentedFetch }));
// Dashboard: always shows providers alphabetically
for (const name of registry.list()) {
console.log(`provider: ${name}`);
}
```
## API Reference
- `class CurrencyProviderRegistry` — exported class. Holds a `Map<string,
CurrencyProvider>` internally.
- `register(provider: CurrencyProvider): void` — store the provider under
`provider.name`. Throws `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and
`providerName === provider.name` when the name is already in use.
- `get(name: string): CurrencyProvider` — return the registered provider.
Throws `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"` and
`providerName === name` when nothing is registered under `name`.
- `has(name: string): boolean` — non-throwing probe.
- `list(): readonly string[]` — sorted snapshot of currently registered
names.
- `unregister(name: string): boolean` — `true` when something was
removed, `false` otherwise. Never throws.
## Implementation Notes
- The registry is **single-tenant**: there is no global / singleton
instance. Callers construct their own registry. Multiple registries can
coexist (e.g. one per tenant in a multi-tenant SaaS), and a provider
instance may be registered with more than one registry simultaneously.
- `register` is idempotent only at the identity level: registering the same
instance under the same name twice still throws
`PROVIDER_ALREADY_REGISTERED`, exactly as registering two different
instances under the same name throws. Idempotent "replace if newer"
semantics belong in a wrapper, not the core, so the contract here stays
unambiguous.
- `list()` returns a fresh `string[]` on every call (the sort happens
in-place on a copy). Callers may treat the result as immutable, but the
registry does not freeze it — a `readonly string[]` type hint conveys the
intent at the TypeScript boundary.
- Both `PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` carry
`providerName` on the thrown `CurrencyProviderError`, so callers writing
cross-cutting error handlers (audit logs, telemetry) can structure on the
discriminant rather than parsing the message string.

View File

@@ -0,0 +1,98 @@
# `configs` — Provider configuration types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the three configuration interfaces every concrete provider in
`@suaveplan/currency-providers` consumes: `ApiKeyConfig` for providers that
require an API key (Fixer, Open Exchange Rates, CoinMarketCap),
`NoKeyConfig` for providers that talk to a public endpoint (ECB, Binance,
CoinGecko, CryptoCompare, Kraken), and `HttpFetchOptions` for the per-call
HTTP knobs callers can set on top. All three are named interfaces — never
inline object literals in provider constructor signatures — so consumers
can import the type, spread it into their own config bag, and let
TypeScript narrow on the discriminating fields.
Every config inherits from `ProviderConfigBase`, which adds the two
universal knobs every provider honours: an optional `baseUrl` (for proxy /
staging / fixture endpoints) and an optional `fetch: FetchLike` (the
injectable WHATWG-shape closure that lets tests pass a controlled stub
without `vi.stubGlobal('fetch', ...)`). The `FetchLike` type comes from
`@suaveplan/types` — `globalThis.fetch` is directly assignable to it.
## Features
- `ApiKeyConfig` — `apiKey: string` (required) + `baseUrl?` + `fetch?`
- `NoKeyConfig` — `baseUrl?` + `fetch?` (no API key field)
- `HttpFetchOptions` — `timeoutMs?` + `headers?` per-call knobs (reserved for
the retry / timeout wrapper landing under `## Out of scope`)
- All interfaces use `readonly` fields so config bags are immutable
## Usage: Basic Example
```ts
import { FixerProvider } from "@suaveplan/currency-providers";
import type { ApiKeyConfig } from "@suaveplan/currency-providers";
const config: ApiKeyConfig = { apiKey: process.env.FIXER_KEY! };
const fixer = new FixerProvider(config);
```
## Usage: Advanced Example
Inject a custom `fetch` for hermetic tests:
```ts
import { EcbProvider } from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
const fakeFetch: FetchLike = async (input) => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/eurofxref-daily.xml")) {
return new Response(
`<?xml version="1.0"?><Cube><Cube currency='USD' rate='1.087'/></Cube>`,
{ status: 200 }
);
}
return new Response("not found", { status: 404 });
};
const ecb = new EcbProvider({
baseUrl: "https://staging.example.com/eurofxref-daily.xml",
fetch: fakeFetch,
});
const result = await ecb.fetchRates({ base: "EUR" });
```
## API Reference
- `interface ProviderConfigBase` — `baseUrl?: string`, `fetch?: FetchLike`.
Internal base shape every concrete config extends.
- `interface ApiKeyConfig extends ProviderConfigBase` — adds
`apiKey: string`. Providers that consume it raise
`CURRENCY_PROVIDER.MISSING_CONFIG` if `apiKey` is empty.
- `interface NoKeyConfig extends ProviderConfigBase` — empty extension; type
alias name signals "no key required". Allows future per-no-key fields
without touching the base.
- `interface HttpFetchOptions` — `timeoutMs?: number`,
`headers?: Readonly<Record<string, string>>`. Reserved for the retry /
timeout wrapper.
## Implementation Notes
- The base interface intentionally accepts `FetchLike` (structural,
three-argument or two-argument doesn't matter) instead of the global
`typeof fetch`. This lets callers pass tighter stubs — e.g. a closure
that only handles a single URL pattern.
- `HttpFetchOptions` exists in the public surface today even though no
provider consumes it yet, because it is exported by the upstream
`@suaveplan/currency-providers` package and consumer code that imported
the type would break if it disappeared. Once `@suaveplan/retry` is wired
in, every provider's `fetchRates` will accept it as a second argument.
- Empty-extension `interface NoKeyConfig extends ProviderConfigBase {}` is
intentional and not a TypeScript anti-pattern: it gives the no-key
providers a distinct, importable type name so consumers can spread it
into their own configs without coupling to the base name.

View File

@@ -0,0 +1,105 @@
# @suaveplan/currency - Universal currency conversion engine — pluggable CurrencyProvider interface, TTL-bounded RateCache with direct / inverse / triangulated lookup, primary + fallback chain with AbortSignal cancellation, Zod-validated public boundary, typed CurrencyError hierarchy with stable CURRENCY.* code registry, virtual-currency seeding with post-construction setVirtualRate / updateVirtualRate mutators, enriched ConversionResult carrying providerName and triangulatedThrough diagnostics. Built on @suaveplan/money minor-unit bigint arithmetic.
Universal currency conversion engine — pluggable `CurrencyProvider`
interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup,
primary + fallback chain with `AbortSignal` cancellation, Zod-validated public
boundary, typed `CurrencyError` hierarchy with stable `CURRENCY.*` code
registry, virtual-currency seeding with post-construction
`setVirtualRate` / `updateVirtualRate` mutators, and enriched `ConversionResult`
carrying `providerName` and `triangulatedThrough` diagnostics. Built on
`@suaveplan/money` minor-unit `bigint` arithmetic.
<!-- begin:auto-docs -->
## Documentation tree
- [converter](./src/converter/converter.md) — `CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It combines a pluggable `CurrencyProvider` (pl…
- [converter](./src/converter/converter.md) — `CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It combines a pluggable `CurrencyProvider` (pl…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/currency` raises only typed errors. This module owns the stable `CURRENCY_CODES` registry (built with `defi…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/currency` raises only typed errors. This module owns the stable `CURRENCY_CODES` registry (built with `defi…
- [provider](./src/provider/provider.md) — The provider module ships two reference implementations of the `CurrencyProvider` contract: a mutable `InMemoryProvider…
- [provider](./src/provider/provider.md) — The provider module ships two reference implementations of the `CurrencyProvider` contract: a mutable `InMemoryProvider…
- [rate-cache](./src/rate-cache/rate-cache.md) — `RateCache` is the converter's in-memory rate store. It folds every successful `fetchRates` answer into a single map ke…
- [rate-cache](./src/rate-cache/rate-cache.md) — `RateCache` is the converter's in-memory rate store. It folds every successful `fetchRates` answer into a single map ke…
- [schemas](./src/schemas/schemas.md) — The schemas module is the package's input-validation boundary. Every public options bundle that crosses into a `Currenc…
- [schemas](./src/schemas/schemas.md) — The schemas module is the package's input-validation boundary. Every public options bundle that crosses into a `Currenc…
- [types](./src/types/common.md) — Single home for every shared type the `@suaveplan/currency` public surface exposes. The module defines the `CurrencyPro…
- [common](./src/types/common.md) — Single home for every shared type the `@suaveplan/currency` public surface exposes. The module defines the `CurrencyPro…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency @suaveplan/money
```
## Quick start
```ts
import { CurrencyConverter, InMemoryProvider } from "@suaveplan/currency";
import { Money } from "@suaveplan/money";
const provider = new InMemoryProvider();
provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 });
const converter = new CurrencyConverter({ provider });
const usd = Money.fromMajor("100", "USD");
const result = await converter.convert(usd, "EUR");
// result.amount === 9200n
// result.rate === 0.92
// result.providerName === "in-memory"
// result.triangulatedThrough === null
const eur = await converter.convertAmount(250, "USD", "EUR");
// eur === 230 (250 * 0.92)
```
## Features
- **Pluggable `CurrencyProvider` interface** for any exchange-rate source.
- **In-memory `RateCache`** with configurable TTL eviction.
- **Cross-rate triangulation** via cached intermediate base (single-hop).
- **Inverse rate calculation** from cached data when only one direction is
known.
- **Provider fallback chain** for resilience — tries providers in order.
- **`AbortSignal` cancellation** on `convert` / `convertAmount` / `getRate`,
honoured between fallback attempts.
- **Zod-validated boundary** on every public option bundle.
- **Typed `CurrencyError` hierarchy** with stable `CURRENCY.*` codes
(`INVALID_CONFIG`, `INVALID_OPTIONS`, `PROVIDER_UNAVAILABLE`,
`UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`, `TRIANGULATION_IMPOSSIBLE`,
`ABORTED`).
- **Virtual-currency seeding** for game currencies, with
`setVirtualRate` / `updateVirtualRate` post-construction mutation.
- **Enriched `ConversionResult`** — `providerName` (`"<identity>"` / `"<cache>"`
/ `"<virtual>"` / actual provider name) plus `triangulatedThrough` (chain of
intermediate currencies for triangulated rates).
- **Built-in `InMemoryProvider` + `createStaticProvider` factory** for tests
and fixed-rate apps.
- **Tree-shakeable subpath exports** per public module.
## Modules
- [`src/converter/converter.md`](./src/converter/converter.md) —
`CurrencyConverter` orchestrator (provider chain, cache, triangulation,
virtual currency, AbortSignal).
- [`src/rate-cache/rate-cache.md`](./src/rate-cache/rate-cache.md) —
TTL-bounded `RateCache` with direct / inverse / triangulated lookup.
- [`src/provider/provider.md`](./src/provider/provider.md) —
`InMemoryProvider` + `createStaticProvider` reference implementations.
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`CURRENCY_CODES` registry + `CurrencyError` discriminated class.
- [`src/schemas/schemas.md`](./src/schemas/schemas.md) — Zod schemas + parse
helpers for every public option bundle.
- [`src/types/common.md`](./src/types/common.md) — public type surface
(`CurrencyProvider`, `ConversionResult`, `ConverterConfig`, …).
## Status
`0.1.0` (pre-graduation). Migrated from
`../suaveplan/packages/finance/currency/` with feature parity plus typed
errors, Zod boundary, `AbortSignal` support, and enriched
`ConversionResult`. See
the design proposal for the migration
record and the deferred-feature list.

View File

@@ -0,0 +1,185 @@
# `converter` — `CurrencyConverter` orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It
combines a pluggable `CurrencyProvider` (plus an ordered fallback chain), a
TTL-bounded `RateCache`, virtual-currency seeding, cross-rate triangulation,
and `AbortSignal` cancellation into a single typed API that converts a
`MoneyLike` value (or plain numeric amount) between any two currencies. Every
public boundary is Zod-validated; every failure path raises a typed
`CurrencyError`; every successful conversion is enriched with `providerName`
and `triangulatedThrough` diagnostics so callers can observe which source
answered and whether the rate is direct or derived.
The converter does not perform I/O itself — it delegates to the provider chain
— which keeps it universal. Reference providers ship in `./provider`;
external providers (ECB, Fixer, CoinGecko, Binance, …) live in
`@suaveplan/currency-providers`. The class is intentionally generic-free; the
markup-policy / multi-tenant work flagged in the enterprise audit will land
in a follow-up change.
## Features
- Primary + ordered fallback `CurrencyProvider` chain (`config.fallbackProviders`).
- TTL-bounded cache (`config.staleDurationMs`, default 1 hour).
- Cross-rate triangulation via cached intermediate base (single-hop;
multi-hop BFS deferred).
- Identity short-circuit when `from === to`.
- Virtual-currency seeding (`config.virtualCurrencies`) with post-construction
`setVirtualRate` / `updateVirtualRate` mutators (aliases) and
`getVirtualRates` inspector. Re-seeded after every provider refresh so
virtual rates survive.
- `AbortSignal` honoured on every public method and between fallback attempts.
- Zod-validated constructor and call sites — failures raise
`CurrencyError({ code: CURRENCY.INVALID_CONFIG | CURRENCY.INVALID_OPTIONS })`.
- Enriched `ConversionResult` — `providerName` (`"<identity>"` / `"<cache>"` /
`"<virtual>"` / actual provider name) and `triangulatedThrough` (the
intermediate base for triangulated rates, otherwise `null`).
- Management surface: `setRate`, `clearCache`, `getCache`.
## Usage: Basic Example
```ts
import { CurrencyConverter, InMemoryProvider } from "@suaveplan/currency";
import { Money } from "@suaveplan/money";
const provider = new InMemoryProvider();
provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 });
const converter = new CurrencyConverter({ provider });
const result = await converter.convert(
Money.fromMajor("100", "USD"),
"EUR"
);
// result.amount === 9200n, result.rate === 0.92,
// result.providerName === "in-memory", result.triangulatedThrough === null
```
## Usage: Advanced Example
A multi-source converter with a fallback provider, virtual currency, and
`AbortSignal` cancellation:
```ts
import {
CurrencyConverter,
InMemoryProvider,
createStaticProvider,
CurrencyError,
CURRENCY_CODES,
} from "@suaveplan/currency";
import { Money } from "@suaveplan/money";
const primary = new InMemoryProvider();
primary.setRates("USD", { EUR: 0.5, JPY: 100 });
const fallback = createStaticProvider("static", {
USD: { EUR: 0.51, JPY: 99 },
});
const converter = new CurrencyConverter({
provider: primary,
fallbackProviders: [fallback],
defaultBase: "USD",
virtualCurrencies: {
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
},
});
// Triangulated conversion via USD.
const eurToJpy = await converter.convert(Money.fromMajor("10", "EUR"), "JPY");
// eurToJpy.triangulatedThrough === ["USD"], eurToJpy.rate === 200
// Virtual-currency conversion.
const gold = await converter.convertAmount(1, "USD", "GOLD");
// gold === 100; converter.getVirtualRates() === { GOLD: 100 }
// Cancellation.
const ac = new AbortController();
queueMicrotask(() => ac.abort());
try {
await converter.convert(Money.fromMajor("1", "USD"), "EUR", {
signal: ac.signal,
fresh: true,
});
} catch (err) {
if (
err instanceof CurrencyError &&
err.code === CURRENCY_CODES.ABORTED
) {
console.warn("Aborted as expected");
} else {
throw err;
}
}
```
## API Reference
### `new CurrencyConverter(config: ConverterConfig)`
Validates `config` through `ConverterConfigSchema`; raises
`CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure.
### `convert(money: MoneyLike, to: CurrencyCode, options?: ConvertOptions): Promise<ConversionResult>`
Converts `money` to `to`. Returns an enriched `ConversionResult`. Raises:
- `CURRENCY.INVALID_OPTIONS` — bad `options`.
- `CURRENCY.ABORTED` — `options.signal` aborted at call time or between
fallback attempts.
- `CURRENCY.PROVIDER_UNAVAILABLE` — every provider in the chain failed.
- `CURRENCY.RATE_UNAVAILABLE` — no rate path exists even after refresh.
### `convertAmount(amount: number, from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise<number>`
Numeric counterpart of `convert`; same errors apply.
### `getRate(from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise<number>`
Returns the multiplicative rate for `from → to`. Same errors apply.
### `setVirtualRate(code: string, rate: number): void` / `updateVirtualRate(code: string, rate: number): void`
Update a virtual currency's rate against `defaultBase` at runtime. Both
names are aliases; the audit named `setVirtualRate`, and the upstream library
named `updateVirtualRate`.
### `getVirtualRates(): Readonly<Record<string, number>>`
Defensive copy of the in-memory virtual rate map.
### `setRate(from: CurrencyCode, to: CurrencyCode, rate: number): void` / `clearCache(): void` / `getCache(): RateCache`
Direct cache management — useful for testing and overrides.
## Implementation Notes
The converter stores a per-pair `rateOrigin` map so it can report
`ConversionResult.providerName` correctly across the four sources
(`<identity>` / `<cache>` / `<virtual>` / actual provider). The
`justFetchedFromProvider` field is set transiently inside `resolveRate` so the
immediate read after a successful fetch surfaces the real provider name;
subsequent calls hit the cache and report `"<cache>"`.
`seedVirtualRates` runs after every successful provider refresh so virtual
rates survive new fetches. It writes virtual entries with origin
`"<virtual>"`; the lookup code therefore reports `"<virtual>"` for any
conversion that targets a virtual currency, even after later fetches refresh
other rates in the same cache entry.
The single-hop triangulation policy preserves upstream behaviour bit-for-bit;
multi-hop BFS is deferred (see proposal `## Out of scope`). The
`triangulatedThrough` field is typed as `readonly string[] | null` to leave
the door open for future multi-hop work without a breaking type change.
`Math.round` on the converted minor amount preserves the upstream rounding
behaviour. The per-pair rounding-mode policy from the enterprise audit is
deferred to a dedicated proposal because it is a behaviour change that
affects every caller.

View File

@@ -0,0 +1,105 @@
# `error-codes` — `CURRENCY_CODES` + `CurrencyError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/currency` raises only typed errors. This module owns the stable
`CURRENCY_CODES` registry (built with `defineCodes("CURRENCY", [...])` from
`@suaveplan/error`) and the `CurrencyError` subclass that every public failure
path constructs. Consumers programmatically dispatch on the namespaced strings
(`"CURRENCY.PROVIDER_UNAVAILABLE"`, `"CURRENCY.RATE_UNAVAILABLE"`, ...) and
inspect the `from` / `to` / `providerName` discriminants to recover from or
report on failures.
The class extends `SuaveplanError<CurrencyErrorCode>`, so
`instanceof CurrencyError` narrows `err.code` to the literal union, and
`instanceof SuaveplanError` carries the full project contract: `.code`,
`.cause`, `.context`, `.timestamp`, `.traceId`. Seven codes are declared:
`INVALID_CONFIG`, `INVALID_OPTIONS`, `PROVIDER_UNAVAILABLE`,
`UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`, `TRIANGULATION_IMPOSSIBLE`,
`ABORTED`. Every code is reachable from a real production call site —
registry pollution is banned by repo policy.
## Features
- Stable namespaced codes via `defineCodes("CURRENCY", [...] as const)` — the
registry is frozen, the namespace is encoded into each value.
- `CurrencyError extends SuaveplanError<CurrencyErrorCode>` — `instanceof`
narrows `.code` to the literal-string union; `cause` chains arbitrary
throwables.
- `from` / `to` / `providerName` discriminants on every error instance for
programmatic diagnostics (`err.from === "USD"`, `err.providerName === "ecb"`).
- Co-located test pins every code to a real call site so the registry never
drifts ahead of the implementation.
## Usage: Basic Example
```ts
import { CurrencyConverter, CurrencyError, CURRENCY_CODES } from "@suaveplan/currency";
try {
await converter.convert(money, "XYZ");
} catch (err) {
if (err instanceof CurrencyError && err.code === CURRENCY_CODES.RATE_UNAVAILABLE) {
console.error(`No rate for ${err.from} → ${err.to}`);
} else {
throw err;
}
}
```
## Usage: Advanced Example
Wrap an upstream provider failure while keeping the original throwable on
`cause` so downstream loggers can still report it, and annotate the typed
error with the provider that triggered the fallback chain failure:
```ts
import { CurrencyError, CURRENCY_CODES } from "@suaveplan/currency";
async function safeFetch(provider) {
try {
return await provider.fetchRates({ base: "USD" });
} catch (err) {
throw new CurrencyError(
CURRENCY_CODES.PROVIDER_UNAVAILABLE,
`provider ${provider.name} failed`,
{ cause: err, providerName: provider.name, from: "USD" }
);
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `CURRENCY_CODES` | `const` (frozen) | Registry of seven namespaced codes built via `defineCodes`. |
| `CurrencyErrorCode` | `type` | Literal-string union of every registry value. |
| `CurrencyErrorOptions` | `interface` | Extends `SuaveplanErrorOptions` with optional `from`, `to`, `providerName`. |
| `CurrencyError` | `class` | `extends SuaveplanError<CurrencyErrorCode>` — constructor takes `(code, message, options?)`. |
### `CurrencyError` properties
- `code: CurrencyErrorCode` — the namespaced error code (e.g. `"CURRENCY.PROVIDER_UNAVAILABLE"`).
- `from?: CurrencyCode` — the source currency the operation was attempted against.
- `to?: CurrencyCode` — the target currency the operation was attempted against.
- `providerName?: string` — the provider whose call produced the error.
- `cause?: unknown` — chained underlying throwable.
- `timestamp: number` — captured at construction time via the injected clock.
- `context?: ErrorContext` — bag of structured fields propagated to the formatter.
- `traceId?: string` — propagated W3C trace identifier when supplied.
## Implementation Notes
The constructor is `public` so any module in the package can throw it; the
optional discriminants are assigned only when set so `JSON.stringify` does not
emit `"from": undefined`. `CurrencyError` is `instanceof SuaveplanError` and
`instanceof Error`; tests pin both. The registry is created with `as const`
so TypeScript infers literal types — `CURRENCY_CODES.PROVIDER_UNAVAILABLE` is
`"CURRENCY.PROVIDER_UNAVAILABLE"`, not `string`. Adding a new code requires
appending to the array in `error-codes.ts`, wiring it into a real production
raise site, and updating the registry test to assert the literal value.

View File

@@ -0,0 +1,108 @@
# `provider` — reference `CurrencyProvider` implementations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The provider module ships two reference implementations of the
`CurrencyProvider` contract: a mutable `InMemoryProvider` for tests and
fixed-rate scenarios, and a `createStaticProvider(name, rateMap)` factory
that returns a frozen provider built from a static rate map. Both share the
same internal helpers for `RateFetchOptions.targets` filtering and
`AbortSignal` handling. External providers (ECB, Fixer, CoinGecko, Binance,
…) live in a separate `@suaveplan/currency-providers` package that consumes
the same contract.
Both reference providers raise `CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE, providerName })`
when no rates are configured for the requested base, and
`CurrencyError({ code: CURRENCY.ABORTED, providerName })` when called with
an already-aborted `AbortSignal`. The `name` field is surfaced on
`ConversionResult.providerName` so consumers can attribute every successful
conversion to its source.
## Features
- `InMemoryProvider` — `setRates(base, rates)`, `setRate(from, to, rate)`,
`fetchRates(options?)`. `supportsCrypto` / `supportsFiat` /
`supportsVirtual` configurable at construction.
- `createStaticProvider(name, rateMap, options?)` — frozen `CurrencyProvider`
serving a static rate map.
- AbortSignal honoured pre-fetch on both implementations.
- `RateFetchOptions.targets` filtering — only the requested target rates are
returned.
- Missing-base error raises typed `CURRENCY.PROVIDER_UNAVAILABLE` with the
provider name on the discriminant.
## Usage: Basic Example
```ts
import { InMemoryProvider, CurrencyConverter } from "@suaveplan/currency";
const provider = new InMemoryProvider();
provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 });
const converter = new CurrencyConverter({ provider });
const eur = await converter.convertAmount(100, "USD", "EUR"); // 92
```
## Usage: Advanced Example
Compose a static fallback chain with `createStaticProvider`, using filtered
target rates to keep payloads small:
```ts
import {
createStaticProvider,
InMemoryProvider,
CurrencyConverter,
} from "@suaveplan/currency";
const primary = new InMemoryProvider();
primary.setRates("USD", { EUR: 0.92 });
const fallback = createStaticProvider(
"static-fallback",
{
USD: { EUR: 0.91, GBP: 0.78 },
},
{ supportsCrypto: false }
);
const converter = new CurrencyConverter({
provider: primary,
fallbackProviders: [fallback],
});
const ac = new AbortController();
queueMicrotask(() => ac.abort());
try {
await converter.convert(
{ amount: 100n, currency: "USD" },
"EUR",
{ signal: ac.signal }
);
} catch (err) {
// err is CurrencyError({ code: "CURRENCY.ABORTED" })
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `InMemoryProvider` | class | `name = "in-memory"`; `setRates`, `setRate`, `fetchRates`. |
| `createStaticProvider(name, rateMap, options?)` | factory | Returns a frozen `CurrencyProvider` answering from `rateMap`. |
| `ReferenceProviderOptions` | interface | Optional `supportsCrypto` / `supportsFiat` / `supportsVirtual` flags. |
## Implementation Notes
`InMemoryProvider` stores rates in a `Map<string, Record<string, number>>`;
`setRates` copies the input object so later caller mutations don't leak.
`createStaticProvider`'s returned object is `Object.freeze`d so callers can't
mutate `name` / `supportsCrypto` / `supportsFiat` after construction. Abort
handling is intentionally pre-fetch only — the reference providers do no
real I/O, so once `fetchRates` resolves there is no in-flight call to
abort. External providers (HTTP, WebSocket) propagate the signal into their
network call.

View File

@@ -0,0 +1,91 @@
# `rate-cache` — TTL-bounded rate cache with triangulated lookup
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RateCache` is the converter's in-memory rate store. It folds every successful
`fetchRates` answer into a single map keyed by base currency, evicts entries
older than the configured TTL, and resolves rates for any pair through one of
four paths: identity (`from === to`), direct (cache has a `from` base with a
`to` rate), inverse (cache has a `to` base with a `from` rate — return
`1 / rate`), or triangulated (any cached non-`from`/`to` base contains both
`from` and `to`). The cache is universal: only `Date.now`, `Map`, and
plain object access.
The cache is a pure storage substrate — it does not call providers, does not
seed virtual rates, and does not raise typed errors. Those concerns live in
`CurrencyConverter`. Keeping the cache concern-free makes it cheap to swap
for a persistent adapter in a follow-up change (see proposal `## Out of
scope`).
## Features
- TTL-bounded entries (`set` records `Date.now() + ttlMs` as `expiresAt`).
- Lazy eviction — expired entries are deleted on `get` / `has`.
- `getRate(from, to)` resolves identity, direct, inverse, and triangulated
matches.
- `getTriangulationPath(from, to)` surfaces the intermediate base used for
triangulation (or `undefined` for direct / inverse / identity / miss).
- `prune()` removes every expired entry and returns the count removed.
- `clear()`, `size`, `getTimestamp(base)` round out the inspection surface.
- Skips zero rates during inverse / triangulation lookup to avoid `Infinity`.
## Usage: Basic Example
```ts
import { RateCache } from "@suaveplan/currency";
const cache = new RateCache(60_000); // 1-minute TTL
cache.set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: Date.now() });
cache.getRate("USD", "EUR"); // 0.92
cache.getRate("EUR", "USD"); // 1 / 0.92 (inverse)
cache.getRate("USD", "USD"); // 1 (identity)
```
## Usage: Advanced Example
Use the triangulation path to log which intermediate base resolved a quote:
```ts
import { RateCache } from "@suaveplan/currency";
const cache = new RateCache();
cache.set("USD", {
base: "USD",
rates: { EUR: 0.5, JPY: 100 },
timestamp: Date.now(),
});
const rate = cache.getRate("EUR", "JPY"); // 200 (100 / 0.5)
const through = cache.getTriangulationPath("EUR", "JPY"); // "USD"
console.log(`EUR → JPY via ${through} = ${rate}`);
```
## API Reference
| Member | Kind | Description |
|---|---|---|
| `new RateCache(ttlMs?)` | constructor | `ttlMs` defaults to `3_600_000` (1 hour). |
| `set(base, result)` | method | Stores a `RateFetchResult` and stamps `expiresAt`. |
| `get(base)` | method | Returns the cached `RateFetchResult` or `undefined`; deletes expired entries. |
| `has(base)` | method | `get(base) !== undefined`. |
| `getRate(from, to)` | method | Number for identity / direct / inverse / triangulated matches, otherwise `undefined`. |
| `getTriangulationPath(from, to)` | method | The intermediate base when triangulation hit, otherwise `undefined`. |
| `getTimestamp(base)` | method | `result.timestamp` of the fresh entry; `undefined` when missing or expired. |
| `prune()` | method | Deletes every expired entry; returns the count removed. |
| `clear()` | method | Empties the cache. |
| `size` | getter | The underlying `Map.size` (includes potentially expired entries until pruned). |
## Implementation Notes
`size` deliberately mirrors the underlying `Map.size` so callers can observe
hold-over expired entries before calling `prune()` — the counterpart `has` is
TTL-aware. Lookup avoids dividing by `0` (a degenerate inverse rate) by
short-circuiting the inverse branch when `rate === 0`; the same guard
protects triangulation against a zero `fromRate`. The triangulation loop
skips bases equal to `from` or `to` because those would be the inverse /
direct path the previous branches already covered.

View File

@@ -0,0 +1,106 @@
# `schemas` — Zod boundary for `@suaveplan/currency`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The schemas module is the package's input-validation boundary. Every public
options bundle that crosses into a `CurrencyConverter` method or a reference
provider is parsed against a Zod schema first; failures raise a typed
`CurrencyError` with code `CURRENCY.INVALID_CONFIG` (constructor arguments,
virtual-currency entries) or `CURRENCY.INVALID_OPTIONS` (per-call options,
`RateFetchOptions`). The Zod issue array is attached to
`error.context.issues` so downstream loggers and APIs can surface field-level
diagnostics without re-running validation.
Centralising validation here keeps the converter and provider implementations
focused on behaviour: by the time control enters `convert` or `fetchRates`,
every option is already the shape the implementation expects, and the
implementation can rely on TypeScript's structural typing without runtime
double-checks.
## Features
- `ConverterConfigSchema` — validates the constructor argument (provider
shape, optional fallback array, positive integer TTL, currency-code
default base, virtual-currency record).
- `ConvertOptionsSchema` — validates per-call options (`rounding` enum,
optional `fresh: boolean`, optional `AbortSignal`).
- `VirtualCurrencyConfigSchema` — `rate > 0`, non-empty `name` / `symbol`,
optional `exponent ∈ [0, 18]` / `issuerId`.
- `RateFetchOptionsSchema` — optional `base`, `targets` array of currency
codes, optional `AbortSignal`.
- `parseConverterConfig` / `parseConvertOptions` /
`parseVirtualCurrencyConfig` / `parseRateFetchOptions` — safe-parse
helpers that raise the appropriate `CurrencyError` on failure.
## Usage: Basic Example
```ts
import { parseConvertOptions } from "@suaveplan/currency";
const options = parseConvertOptions({ rounding: "half-up", fresh: false });
// options is typed as ConvertOptions; bad shapes raise CURRENCY.INVALID_OPTIONS.
```
## Usage: Advanced Example
A consumer wraps API input through the schemas before delegating to the
converter, surfacing Zod issues as user-facing field errors:
```ts
import {
CurrencyError,
CURRENCY_CODES,
parseConverterConfig,
CurrencyConverter,
} from "@suaveplan/currency";
export function makeConverter(raw: unknown) {
try {
const config = parseConverterConfig(raw);
return new CurrencyConverter(config);
} catch (err) {
if (
err instanceof CurrencyError &&
err.code === CURRENCY_CODES.INVALID_CONFIG
) {
const issues = (err.context?.issues as { path: (string | number)[]; message: string }[] | undefined) ?? [];
throw new Error(
`Bad converter config: ${issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join(", ")}`
);
}
throw err;
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `ConverterConfigSchema` | Zod schema | Validates `ConverterConfig`. |
| `ConvertOptionsSchema` | Zod schema | Validates `ConvertOptions`. |
| `VirtualCurrencyConfigSchema` | Zod schema | Validates a single `VirtualCurrencyConfig`. |
| `RateFetchOptionsSchema` | Zod schema | Validates `RateFetchOptions`. |
| `parseConverterConfig(input)` | `(unknown) => ConverterConfig` | Raises `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure. |
| `parseConvertOptions(input)` | `(unknown) => ConvertOptions` | Raises `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure. |
| `parseVirtualCurrencyConfig(input)` | `(unknown) => VirtualCurrencyConfig` | Raises `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure. |
| `parseRateFetchOptions(input)` | `(unknown) => RateFetchOptions` | Raises `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure. |
## Implementation Notes
The `signal` field uses a `z.custom<AbortSignal>` because `AbortSignal` is a
host object — Zod cannot introspect it. The custom predicate accepts any
object exposing an `aborted` property, which matches both the platform
`AbortSignal` and consumer-side polyfills. `provider` is validated
structurally (`name`, `supportsCrypto`, `supportsFiat`, optional
`supportsVirtual`, function-typed `fetchRates`); we deliberately do not
descend into the function body. Currency codes are validated as non-empty
strings — the converter checks against the `@suaveplan/money` registry at
use time, so unknown codes raise `CURRENCY.UNSUPPORTED_CURRENCY` (via
`getCurrencyOrThrow`) rather than failing schema validation.

View File

@@ -0,0 +1,112 @@
# `types/common` — public type surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single home for every shared type the `@suaveplan/currency` public surface
exposes. The module defines the `CurrencyProvider` contract every rate source
implements, the option bundles for the converter constructor and its three
public methods (`convert`, `convertAmount`, `getRate`), the `RateCache` wire
shape (`RateFetchResult`), and the enriched `ConversionResult` returned by
`convert`. Every interface is named (no inline shapes — AGENTS.md §11) so
downstream consumers can import the same name and structural-type assertions
stay tight.
Types live here rather than inside their feature module because they cross
module boundaries: the converter consumes `CurrencyProvider`, the provider
module satisfies it, the cache stores `RateFetchResult`, and the schemas module
mirrors each bundle into a Zod parser. Centralising them keeps the contract in
one auditable place.
## Features
- `CurrencyProvider` contract: `name`, `supportsCrypto`, `supportsFiat`,
optional `supportsVirtual`, `fetchRates(options?)`.
- `RateFetchOptions` / `RateFetchResult` — the wire shape every provider speaks.
- `ConverterConfig` — constructor arg for `CurrencyConverter`.
- `ConvertOptions` — per-call options bundle (rounding, fresh, signal).
- `ConversionResult` — converter return shape; enriched beyond upstream with
`providerName` and `triangulatedThrough` diagnostics.
- `VirtualCurrencyConfig` — fixed-rate virtual / game currency configuration.
- `ExchangeRate` — single-pair wire shape (reserved for external transports).
## Usage: Basic Example
```ts
import type {
CurrencyProvider,
ConverterConfig,
ConversionResult,
} from "@suaveplan/currency";
const provider: CurrencyProvider = {
name: "ecb",
supportsCrypto: false,
supportsFiat: true,
async fetchRates() {
return { base: "EUR", rates: { USD: 1.08 }, timestamp: Date.now() };
},
};
const config: ConverterConfig = { provider, defaultBase: "EUR" };
```
## Usage: Advanced Example
A consumer can satisfy the `CurrencyProvider` contract with an external HTTP
client and surface its identifier through `ConversionResult.providerName`:
```ts
import type { CurrencyProvider, RateFetchResult } from "@suaveplan/currency";
class EcbProvider implements CurrencyProvider {
readonly name = "ecb";
readonly supportsCrypto = false;
readonly supportsFiat = true;
async fetchRates({
base = "EUR",
targets,
signal,
} = {}): Promise<RateFetchResult> {
const res = await fetch(`https://api.example.com/${base}`, { signal });
const data = (await res.json()) as { rates: Record<string, number> };
let rates = data.rates;
if (targets) {
const filtered: Record<string, number> = {};
for (const t of targets) {
if (rates[t] !== undefined) filtered[t] = rates[t];
}
rates = filtered;
}
return { base, rates, timestamp: Date.now() };
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `CurrencyProvider` | `interface` | Rate-source contract — `name`, `supportsCrypto`, `supportsFiat`, optional `supportsVirtual`, `fetchRates`. |
| `RateFetchOptions` | `interface` | `base?`, `targets?`, `signal?`. |
| `RateFetchResult` | `interface` | `base`, `rates`, `timestamp`. |
| `ExchangeRate` | `interface` | Single-pair wire shape: `from`, `to`, `rate`, `timestamp`. |
| `VirtualCurrencyConfig` | `interface` | `rate`, `name`, `symbol`, optional `exponent` / `issuerId`. |
| `ConverterConfig` | `interface` | `provider`, optional `fallbackProviders`, `staleDurationMs`, `defaultBase`, `virtualCurrencies`. |
| `ConvertOptions` | `interface` | `rounding?`, `fresh?`, `signal?`. |
| `ConversionResult` | `interface` | `amount`, `rate`, `inverseRate`, `from`, `to`, `rateAgeMs`, `providerName`, `triangulatedThrough`. |
## Implementation Notes
Every field is `readonly` so consumers can confidently pass results around
without defensive copies; the converter never mutates a `RateFetchResult`
after handing it to the cache. `signal?: AbortSignal` is intentionally
optional on every options bundle so callers that have no cancellation source
need not synthesise one. `triangulatedThrough` is typed as
`readonly string[] | null` (not `string | null`) so future multi-hop BFS work
does not require a breaking type change; the migration baseline emits arrays
of length 0 or 1 only.

View File

@@ -0,0 +1,120 @@
# @suaveplan/money - Universal precise currency/money arithmetic using integer minor-unit (bigint) representation: immutable Money class with full arithmetic + comparison + conversion surface, locale-aware Intl-backed formatting (formatMoney, formatMoneyCompact), fair allocators preserving the input total (allocateEvenly, allocateByRatios, allocateByPercentages), aggregators (sumMoney, minMoney, maxMoney), 30 ISO 4217 fiat + 5 cryptocurrency built-ins, custom and virtual currency registration, lossless JSON round-trip, typed MoneyError hierarchy with stable MONEY.* code registry.
Universal precise currency and money arithmetic using integer minor-unit
representation. All operations use `bigint` so consumers never hit
floating-point rounding drift; cross-currency mistakes raise typed errors;
allocations preserve the input total to the minor unit.
<!-- begin:auto-docs -->
## Documentation tree
- [allocator](./src/allocator/allocator.md) — The allocator module solves two related problems: splitting a `Money` amount across N recipients (or N weights or N per…
- [allocator](./src/allocator/allocator.md) — The allocator module solves two related problems: splitting a `Money` amount across N recipients (or N weights or N per…
- [currencies](./src/currencies/currencies.md) — This module owns the currency-metadata registry that every other part of `@suaveplan/money` consults to compute minor-u…
- [currencies](./src/currencies/currencies.md) — This module owns the currency-metadata registry that every other part of `@suaveplan/money` consults to compute minor-u…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/money` raises only typed errors. This module owns the stable `MONEY_CODES` registry (built with `defineCode…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/money` raises only typed errors. This module owns the stable `MONEY_CODES` registry (built with `defineCode…
- [formatter](./src/formatter/formatter.md) — The formatter renders `MoneyLike` values to human-readable strings using `Intl.NumberFormat(locale)`-derived separators…
- [formatter](./src/formatter/formatter.md) — The formatter renders `MoneyLike` values to human-readable strings using `Intl.NumberFormat(locale)`-derived separators…
- [money](./src/money/money.md) — `Money` is the cornerstone of the package: an immutable value object that stores monetary amounts as `bigint` minor uni…
- [money](./src/money/money.md) — `Money` is the cornerstone of the package: an immutable value object that stores monetary amounts as `bigint` minor uni…
- [types](./src/types/common.md) — `@suaveplan/money` exposes a small, stable set of types that downstream packages (`@suaveplan/currency`, `@suaveplan/pa…
- [common](./src/types/common.md) — `@suaveplan/money` exposes a small, stable set of types that downstream packages (`@suaveplan/currency`, `@suaveplan/pa…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/money
```
## Quick start
```ts
import { Money, formatMoney, allocateEvenly } from "@suaveplan/money";
const price = Money.fromMajor(29.99, "USD");
const tax = price.multiply(0.08);
const total = price.add(tax);
console.log(formatMoney(total)); // "$32.39"
console.log(total.toDecimalString()); // "32.39"
// Split a $32.39 bill among 3 — no penny lost
const { shares } = allocateEvenly(total, 3);
console.log(shares.map((s) => formatMoney(s))); // ["$10.80", "$10.80", "$10.79"]
```
## Features
- **Immutable `Money` class** with full arithmetic (`add`, `subtract`,
`multiply`, `divide`, `abs`, `negate`), comparison (`compareTo`, `equals`,
`greaterThan`, ...), and conversion (`toMajor`, `toDecimalString`, `toJSON`,
`toString`).
- **30 ISO 4217 fiat currencies** (USD, EUR, GBP, JPY, ...) plus **5
cryptocurrencies** (BTC, ETH, SOL, USDT, USDC) with accurate exponents.
- **Custom and virtual currency registration** via `registerCurrency(info)`
with `virtual` / `issuerId` flags for game-currency use cases.
- **Locale-aware formatting** through `Intl.NumberFormat`-derived separators;
`decimalSeparator` / `thousandsSeparator` overrides for locked output.
- **Compact notation** (`formatMoneyCompact`) with K / M / B suffixes.
- **Fair allocators** (`allocateEvenly`, `allocateByRatios`,
`allocateByPercentages`) that always preserve the input total.
- **Aggregators** (`sumMoney`, `minMoney`, `maxMoney`) with typed empty-input
and cross-currency guards.
- **Lossless JSON round-trip** via `Money.toJSON()` / `Money.fromJSON()`
(`bigint` encoded as decimal string).
- **Typed `MoneyError` hierarchy** extending `SuaveplanError<MoneyErrorCode>`
with stable `MONEY.*` code registry and `currency` / `otherCurrency`
discriminants.
- **Subpath exports** for every public module so consumers can tree-shake.
- **Universal** — no `node:*`, `bun:*`, DOM, or fs imports; runs unchanged
on Node ≥ 18, Bun, browsers, and Web Workers.
## Modules
- [`money/money.md`](./src/money/money.md) — `Money` class + `applyRounding`
- [`currencies/currencies.md`](./src/currencies/currencies.md) — built-in
registry, lookup, custom / virtual currency registration
- [`formatter/formatter.md`](./src/formatter/formatter.md) — `formatMoney` +
`formatMoneyCompact`
- [`allocator/allocator.md`](./src/allocator/allocator.md) — allocators +
aggregators (`sumMoney`, `minMoney`, `maxMoney`)
- [`error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`MONEY_CODES` registry + `MoneyError` class
- [`types/common.md`](./src/types/common.md) — public type contracts
## Subpath exports
```ts
import { Money } from "@suaveplan/money/money/money";
import { formatMoney } from "@suaveplan/money/formatter/formatter";
import { allocateEvenly } from "@suaveplan/money/allocator/allocator";
import { registerCurrency } from "@suaveplan/money/currencies/currencies";
import { MoneyError } from "@suaveplan/money/error-codes/error-codes";
import type { MoneyLike } from "@suaveplan/money/types/common";
```
## Error handling
Every failure path raises a typed `MoneyError` carrying a stable
`MONEY.*` code. Six codes are declared: `CURRENCY_MISMATCH`,
`UNKNOWN_CURRENCY`, `DIVISION_BY_ZERO`, `INVALID_ALLOCATION`, `EMPTY_INPUT`,
`PARSE_ERROR`.
```ts
import { Money, MoneyError, MONEY_CODES } from "@suaveplan/money";
try {
Money.fromMinor(100n, "USD").add(Money.fromMinor(50n, "EUR"));
} catch (err) {
if (err instanceof MoneyError && err.code === MONEY_CODES.CURRENCY_MISMATCH) {
console.error(`mismatch: ${err.currency} vs ${err.otherCurrency}`);
}
}
```
## License
MIT — © Suaveplan Team

View File

@@ -0,0 +1,144 @@
# `allocator` — fair-share distribution + aggregation helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The allocator module solves two related problems: splitting a `Money` amount
across N recipients (or N weights or N percentages) without inventing or losing
minor units, and folding many `Money` values into a single extremum or sum.
The allocators (`allocateEvenly`, `allocateByRatios`, `allocateByPercentages`)
all return an `AllocationResult { shares, remainder }` where `sumMoney(shares)
=== input.amount` — this invariant holds even when integer division produces
fractional minor units, because remainders are distributed round-robin to the
leading shares. Negative amounts are handled symmetrically (the sign is
preserved on each distributed remainder). The `remainder` field is always
`0n` today; it is kept on the return shape for forward-compatibility with
allocators that might surface the unallocated portion.
The aggregators (`sumMoney`, `minMoney`, `maxMoney`) raise typed errors on
empty input (`MONEY.EMPTY_INPUT`) and on heterogeneous-currency input
(`MONEY.CURRENCY_MISMATCH`) — silent currency mixing would lead to nonsensical
totals.
## Features
- `allocateEvenly` — split into N equal parts with round-robin remainder distribution.
- `allocateByRatios` — split by relative weights; weights can be fractional.
- `allocateByPercentages` — split by exact percentages (must sum to 100 ± 0.001).
- `sumMoney` / `minMoney` / `maxMoney` — typed aggregators with cross-currency guards.
- Sum-equals-input invariant guaranteed by every allocator.
- Negative inputs handled with sign preservation.
- All errors raise typed `MoneyError` with stable `MONEY.*` codes.
## Usage: Basic Example
```ts
import {
allocateEvenly,
allocateByRatios,
allocateByPercentages,
sumMoney,
minMoney,
maxMoney,
Money,
} from "@suaveplan/money";
// Split a $10 bill among 3 people — no penny lost
const evenly = allocateEvenly({ amount: 1000n, currency: "USD" }, 3);
console.log(evenly.shares.map((s) => s.amount)); // [334n, 333n, 333n]
// Split £10,000 revenue 50/30/20
const revenue = Money.fromMajor(10000, "GBP");
const splits = allocateByRatios(revenue, [50, 30, 20]);
console.log(splits.shares.map((s) => s.toString()));
// Aggregators
console.log(sumMoney([Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD")]).amount); // 150n
console.log(minMoney(Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD")).amount); // 50n
console.log(maxMoney(Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD")).amount); // 100n
```
## Usage: Advanced Example
Use ratios with fractional weights to model a tiered payout, and verify the
sum-equals-input invariant explicitly for an auditable distribution:
```ts
import {
allocateByRatios,
sumMoney,
MoneyError,
MONEY_CODES,
} from "@suaveplan/money";
import type { MoneyLike } from "@suaveplan/money";
interface Payout {
party: string;
weight: number;
}
function distribute(total: MoneyLike, payouts: readonly Payout[]): Map<string, MoneyLike> {
const result = allocateByRatios(total, payouts.map((p) => p.weight));
const sum = sumMoney(result.shares);
if (sum.amount !== total.amount) {
// This branch is impossible — invariant guaranteed — but defensive code
// documents the contract for future maintainers.
throw new MoneyError(MONEY_CODES.INVALID_ALLOCATION, "invariant broken");
}
return new Map(payouts.map((p, i) => [p.party, result.shares[i] as MoneyLike]));
}
// Three-tier payout: a, b, c receive 1.5, 1.0, 0.5 weight units
const distribution = distribute(
{ amount: 1000n, currency: "USD" },
[
{ party: "a", weight: 1.5 },
{ party: "b", weight: 1.0 },
{ party: "c", weight: 0.5 },
]
);
```
## API Reference
### Allocators
| Function | Signature | Throws |
|---|---|---|
| `allocateEvenly` | `(money: MoneyLike, parts: number) => AllocationResult` | `MoneyError({ code: MONEY.INVALID_ALLOCATION })` when `parts <= 0`. |
| `allocateByRatios` | `(money: MoneyLike, ratios: readonly number[]) => AllocationResult` | `MoneyError({ code: MONEY.INVALID_ALLOCATION })` on empty array, negative ratio, or total ratio ≤ 0. |
| `allocateByPercentages` | `(money: MoneyLike, percentages: readonly number[]) => AllocationResult` | `MoneyError({ code: MONEY.INVALID_ALLOCATION })` when percentages don't sum to 100 (± 0.001). |
### Aggregators
| Function | Signature | Throws |
|---|---|---|
| `sumMoney` | `(values: readonly MoneyLike[]) => Money` | `MoneyError({ code: MONEY.EMPTY_INPUT })` on empty array; `MoneyError({ code: MONEY.CURRENCY_MISMATCH })` on cross-currency. |
| `minMoney` | `(...values: MoneyLike[]) => Money` | Same as `sumMoney`. |
| `maxMoney` | `(...values: MoneyLike[]) => Money` | Same as `sumMoney`. |
## Implementation Notes
`allocateEvenly` uses `bigint` integer division: `base = amount / n`, `remainder
= amount % n`. The first `|remainder|` shares each get an extra unit (with the
sign of the original amount), so negative inputs distribute negative
remainders. The result `remainder` is always `0n` because the entire input is
distributed into `shares`.
`allocateByRatios` multiplies the ratio by `1000` before converting to `bigint`
to preserve three decimal places of weight precision (`Math.round(ratio *
1000)`). The post-pass distributes any rounding residue round-robin to keep the
sum-equals-input invariant. This handles both positive and negative inputs.
`allocateByPercentages` is a thin wrapper that validates the input sum is
within `0.001` of `100` (a tolerance chosen to permit floating-point summing of
literal percentage values) and then delegates to `allocateByRatios`.
The aggregators take varargs (`minMoney`, `maxMoney`) or an array (`sumMoney`)
to match upstream — both shapes are convenient at call sites. Empty input is a
typed error rather than `undefined` because returning `undefined` would force
every caller to nullcheck and the empty case is almost always a bug.

View File

@@ -0,0 +1,119 @@
# `currencies` — built-in registry + lookup + registration
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the currency-metadata registry that every other part of
`@suaveplan/money` consults to compute minor-unit precision, format output, and
validate inputs. It ships with 30 ISO 4217 fiat entries (USD, EUR, GBP, JPY,
CHF, CAD, AUD, CNY, INR, BRL, KRW, MXN, SGD, HKD, NOK, SEK, DKK, NZD, ZAR, PLN,
THB, TRY, TWD, AED, SAR, ILS, RUB, CZK, CLP, PHP) and 5 cryptocurrencies (BTC,
ETH, SOL, USDT, USDC) whose exponents match the canonical smallest-unit
definitions (satoshi, wei, lamport, etc.).
Callers register custom or virtual currencies through `registerCurrency(info)` —
this mutates a module-level `Map` so subsequent lookups across the entire
process observe the change. The semantic is deliberate (matches upstream)
because the alternative — passing a registry instance through every API — would
break the single-import-and-use ergonomic that consumers expect. A
constructor-injectable `CurrencyRegistry` is on the roadmap for multi-tenant
process sharing but is out of scope for this migration.
## Features
- 30 ISO 4217 fiat entries + 5 cryptocurrency entries, vetted exponents and Unicode symbols.
- `getCurrency(code)` — `O(1)` lookup; returns `undefined` for unknown codes.
- `getCurrencyOrThrow(code)` — raises typed `MoneyError({ code: MONEY.UNKNOWN_CURRENCY, currency })` when neither built-in nor custom.
- `registerCurrency(info)` — register custom or override built-in; observable globally.
- `getAllCurrencyCodes()` — sorted list of every registered code (built-in + custom).
- `getVirtualCurrencies()` / `isVirtualCurrency(code)` — helpers for game-currency callers.
- `minorUnitMultiplier(exponent)` — `10n ** BigInt(exponent)` helper used by the formatter and parser.
## Usage: Basic Example
```ts
import { getCurrency, getCurrencyOrThrow, registerCurrency } from "@suaveplan/money";
// Look up a built-in
const usd = getCurrency("USD"); // { code: "USD", exponent: 2, symbol: "$", name: "US Dollar" }
// Throw for unknown
try {
getCurrencyOrThrow("XYZ");
} catch (err) {
// MoneyError with code "MONEY.UNKNOWN_CURRENCY" and err.currency === "XYZ"
}
// Register a custom loyalty-points currency
registerCurrency({
code: "PTS",
exponent: 0,
symbol: "pts",
name: "Loyalty Points",
});
```
## Usage: Advanced Example
Register a virtual / game currency with an issuer identifier and use the
virtual-currency helpers to iterate every game-side balance:
```ts
import {
registerCurrency,
getVirtualCurrencies,
isVirtualCurrency,
Money,
formatMoney,
} from "@suaveplan/money";
registerCurrency({
code: "GEM",
exponent: 0,
symbol: "💎",
name: "Game Gem",
virtual: true,
issuerId: "game-op-1",
});
const balance = Money.fromMinor(5000n, "GEM");
console.log(formatMoney(balance)); // "💎5000"
console.log(isVirtualCurrency("GEM")); // true
// Enumerate every registered virtual currency for an audit report
for (const info of getVirtualCurrencies()) {
console.log(`${info.code} issued by ${info.issuerId ?? "<unknown>"}`);
}
```
## API Reference
| Export | Signature | Description |
|---|---|---|
| `getCurrency` | `(code: CurrencyCode) => CurrencyInfo \| undefined` | Look up metadata; returns `undefined` when unknown. |
| `getCurrencyOrThrow` | `(code: CurrencyCode) => CurrencyInfo` | Look up metadata; raises `MoneyError({ code: MONEY.UNKNOWN_CURRENCY, currency })` when unknown. |
| `registerCurrency` | `(info: CurrencyInfo) => void` | Add or override an entry. Custom entries take precedence over built-ins. |
| `getAllCurrencyCodes` | `() => CurrencyCode[]` | Returns every registered code (built-in + custom), sorted ascending. |
| `isVirtualCurrency` | `(code: CurrencyCode) => boolean` | `true` when the entry exists and has `virtual === true`. |
| `getVirtualCurrencies` | `() => CurrencyInfo[]` | Every registered entry whose `virtual` flag is `true`. |
| `minorUnitMultiplier` | `(exponent: number) => bigint` | Compute `10n ** BigInt(exponent)`. |
## Implementation Notes
The registry uses two maps: a frozen built-in `Record<string, CurrencyInfo>`
literal and a mutable `Map<string, CurrencyInfo>` for customs. Lookups consult
the customs map first so callers can override built-ins (useful for test
fixtures that want a deterministic symbol or exponent).
`registerCurrency` mutates module-level state. This is deliberate but it means
test suites must restore prior state when overriding built-ins; the test file
demonstrates the try/finally pattern. A scoped `CurrencyRegistry` instance is
on the roadmap for multi-tenant servers that need isolated registries — see
`enterprise/money.md` §3.
`minorUnitMultiplier(exponent)` uses `BigInt(exponent)` so `10n ** BigInt(18)`
produces `1_000_000_000_000_000_000n` without precision loss — the same value
ETH uses for wei.

View File

@@ -0,0 +1,107 @@
# `error-codes` — `MONEY_CODES` + `MoneyError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/money` raises only typed errors. This module owns the stable
`MONEY_CODES` registry (built with `defineCodes("MONEY", [...])` from
`@suaveplan/error`) and the `MoneyError` subclass that every public failure path
constructs. Consumers programmatically dispatch on the namespaced strings
(`"MONEY.CURRENCY_MISMATCH"`, `"MONEY.UNKNOWN_CURRENCY"`, ...) and inspect the
`currency` / `otherCurrency` discriminants to recover from or report on
failures.
The class extends `SuaveplanError<MoneyErrorCode>`, so `instanceof MoneyError`
narrows `err.code` to the literal union, and `instanceof SuaveplanError` carries
the full project contract: `.code`, `.cause`, `.context`, `.timestamp`,
`.traceId`. Six codes are declared: `CURRENCY_MISMATCH`, `UNKNOWN_CURRENCY`,
`DIVISION_BY_ZERO`, `INVALID_ALLOCATION`, `EMPTY_INPUT`, `PARSE_ERROR`. Every
code is reachable from a real production call site — registry pollution is
banned by repo policy.
## Features
- Stable namespaced codes via `defineCodes("MONEY", [...] as const)` — the
registry is frozen, the namespace is encoded into each value.
- `MoneyError extends SuaveplanError<MoneyErrorCode>` — `instanceof` narrows
`.code` to the literal-string union; `cause` chains arbitrary throwables.
- `currency` / `otherCurrency` discriminants on every error instance for
programmatic diagnostics (`err.currency === "USD"`).
- Co-located test pinning every code to a real call site so the registry never
drifts ahead of the implementation.
## Usage: Basic Example
```ts
import { Money, MoneyError, MONEY_CODES } from "@suaveplan/money";
try {
Money.fromMinor(100n, "USD").add(Money.fromMinor(50n, "EUR"));
} catch (err) {
if (err instanceof MoneyError && err.code === MONEY_CODES.CURRENCY_MISMATCH) {
console.error(`Cannot mix ${err.currency} with ${err.otherCurrency}`);
} else {
throw err;
}
}
```
## Usage: Advanced Example
Use the `cause` chain to wrap an external failure with stable code routing while
preserving the original error for upstream loggers:
```ts
import { MoneyError, MONEY_CODES } from "@suaveplan/money";
function parseUserInput(raw: string, currency: string) {
try {
return Money.fromMajor(raw, currency);
} catch (err) {
if (err instanceof MoneyError && err.code === MONEY_CODES.PARSE_ERROR) {
// Wrap with our own contextual error so downstream callers see the
// user-facing message but still recover the underlying MoneyError
// via .cause.
throw new MoneyError(
MONEY_CODES.PARSE_ERROR,
`Field "amount" could not be parsed`,
{ cause: err, currency }
);
}
throw err;
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `MONEY_CODES` | `const` (frozen) | Registry of six namespaced codes built via `defineCodes`. |
| `MoneyErrorCode` | `type` | Literal-string union of every registry value. |
| `MoneyErrorOptions` | `interface` | Extends `SuaveplanErrorOptions` with optional `currency` and `otherCurrency`. |
| `MoneyError` | `class` | `extends SuaveplanError<MoneyErrorCode>` — constructor takes `(code, message, options?)`. |
### `MoneyError` properties
- `code: MoneyErrorCode` — the namespaced error code (e.g. `"MONEY.CURRENCY_MISMATCH"`).
- `currency?: CurrencyCode` — the currency the operation was attempted against.
- `otherCurrency?: CurrencyCode` — the second currency in a mismatch pair.
- `cause?: unknown` — chained underlying throwable.
- `timestamp: number` — captured at construction time via the injected clock.
- `context?: ErrorContext` — bag of structured fields propagated to the formatter.
- `traceId?: string` — propagated W3C trace identifier when supplied.
## Implementation Notes
The constructor is `public` so any module in the package can throw it; the
optional discriminants are assigned only when set so `JSON.stringify` does not
emit `"currency": undefined`. `MoneyError` is `instanceof SuaveplanError` and
`instanceof Error`; tests pin both. The registry is created with
`as const` so TypeScript infers literal types — `MONEY_CODES.CURRENCY_MISMATCH`
is `"MONEY.CURRENCY_MISMATCH"`, not `string`. Adding a new code requires
appending to the array in `error-codes.ts`, wiring it into a real production
raise site, and updating the `MONEY_CODES` test to assert the literal value.

View File

@@ -0,0 +1,144 @@
# `formatter` — locale-aware human-readable Money rendering
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The formatter renders `MoneyLike` values to human-readable strings using
`Intl.NumberFormat(locale)`-derived separators. Two entry points cover the
common cases: `formatMoney(money, options?)` produces a full-precision string
honoring the currency exponent and locale conventions, while
`formatMoneyCompact(money)` collapses large values into `K` / `M` / `B`
notation for dashboards and summary widgets.
Unlike `Intl.NumberFormat`'s built-in currency formatter, this implementation
preserves `bigint` precision on the integer portion — it never casts the
amount through `Number` before formatting, so values with more than 15
significant digits (common for ETH amounts in wei) render exactly. The locale
contribution is limited to deriving the decimal and thousands separators, both
of which can be overridden via `decimalSeparator` / `thousandsSeparator` for
callers who need locked output.
## Features
- Locale-aware decimal + thousands separators via `Intl.NumberFormat`.
- `showSymbol` (default `true`) / `showCode` (default `false`) selectable display modes.
- `decimalSeparator` / `thousandsSeparator` overrides that win over locale.
- `trailingZeros` flag to keep or trim trailing zeros in the fractional part.
- Zero-exponent currencies (JPY, KRW, CLP) format without a decimal point.
- Negative amounts prepend `-` before the symbol.
- Compact mode with K / M / B suffixes and currency-precision fallback for small values.
- Universal (only `Intl.NumberFormat` is touched — no `node:`, `bun:`, DOM, or fs).
## Usage: Basic Example
```ts
import { formatMoney, formatMoneyCompact, Money } from "@suaveplan/money";
formatMoney({ amount: 1050n, currency: "USD" });
// "$10.50"
formatMoney({ amount: 1050n, currency: "USD" }, { showCode: true });
// "10.50 USD"
formatMoney({ amount: 105050n, currency: "EUR" }, { locale: "de-DE" });
// "€1.050,50"
formatMoneyCompact(Money.fromMajor(2_500_000, "USD"));
// "$2.5M"
```
## Usage: Advanced Example
Use the override knobs to format consistently for a multi-tenant report where
each tenant's locale differs but the corporate style guide demands the same
visual format (US-style thousands separator, no trailing zeros):
```ts
import { formatMoney, sumMoney } from "@suaveplan/money";
import type { MoneyLike } from "@suaveplan/money";
interface TenantInvoice {
tenantId: string;
locale: string;
items: readonly MoneyLike[];
}
function renderTenantTotal(invoice: TenantInvoice): string {
const total = sumMoney(invoice.items);
return formatMoney(total, {
locale: invoice.locale,
// Lock thousands + decimal separators to corporate-style, no trailing zeros.
thousandsSeparator: ",",
decimalSeparator: ".",
trailingZeros: false,
});
}
const out = renderTenantTotal({
tenantId: "t-1",
locale: "de-DE",
items: [
{ amount: 123_45n, currency: "EUR" },
{ amount: 876_55n, currency: "EUR" },
],
});
console.log(out); // "€1,000" — locked separators win even with de-DE locale
```
## API Reference
### `formatMoney(money, options?)`
| Argument | Type | Description |
|---|---|---|
| `money` | `MoneyLike` | The value to format. |
| `options.locale` | `string` (default `"en-US"`) | BCP 47 locale tag for separator derivation. |
| `options.showSymbol` | `boolean` (default `true`) | Prefix the currency symbol. |
| `options.showCode` | `boolean` (default `false`) | Suffix the ISO code; suppresses the symbol when `true`. |
| `options.decimalSeparator` | `string` | Override the Intl-derived decimal separator. |
| `options.thousandsSeparator` | `string` | Override the Intl-derived thousands separator. |
| `options.trailingZeros` | `boolean` (default `true`) | Include trailing zeros in the fractional part. |
Returns `string`. Throws `MoneyError({ code: MONEY.UNKNOWN_CURRENCY })` if the
currency is not registered.
### `formatMoneyCompact(money)`
Returns a compact string:
- `amount ≥ 1e9` → `<n.n>B` (e.g. `"$1.5B"`)
- `amount ≥ 1e6` → `<n.n>M`
- `amount ≥ 1e3` → `<n.n>K`
- smaller → currency-precision fixed-point (`exponent` decimals, capped at 2)
Returns `string`. Throws `MoneyError({ code: MONEY.UNKNOWN_CURRENCY })` if the
currency is not registered.
## Implementation Notes
The formatter never casts the integer portion through `Number` — it divides the
`bigint` amount by the `bigint` multiplier and renders both parts as strings.
Only `formatMoneyCompact` uses a `Number` cast (deliberately — compact output
is approximate by design and cannot represent precision differences between
`1.5K` and `1.50000001K`).
Locale-aware separator derivation uses `Intl.NumberFormat(locale).formatToParts`
on a probe value (`1.1` for decimal, `1000` for group). Every modern locale ICU
recognises includes both parts, so the `find` returns a defined entry — the
type assertion in the helpers is safe and avoids a dead fallback branch that
would inflate coverage tracking.
Output assembly:
1. Compute `whole = abs / multiplier`, `frac = abs % multiplier`.
2. Format `whole` with grouping (`formatWholePart`).
3. Format `frac` with padding-to-exponent (`formatFracPart`), optionally
trimming trailing zeros.
4. Join with the locale (or override) separator.
5. Prefix `-` for negative, then apply `showCode` / `showSymbol` policy.
The compact path uses `(abs / 1_000_000_000).toFixed(1)` etc., which produces a
single-decimal output even for round numbers (`$7.0B`).

View File

@@ -0,0 +1,150 @@
# `money` — immutable Money value type + `applyRounding`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Money` is the cornerstone of the package: an immutable value object that
stores monetary amounts as `bigint` minor units (cents, satoshi, lamports, wei)
together with an ISO 4217 (or caller-registered) `currency` code. Every
arithmetic, comparison, and conversion method returns a new instance —
mutations are impossible — so callers can pass `Money` values around freely
without defensive copying.
The class is `implements MoneyLike` so any function expecting `{ readonly
amount: bigint, readonly currency: CurrencyCode }` accepts a `Money` directly.
Static constructors handle every common entry point: `fromMajor` for
floating-point or decimal-string inputs, `fromMinor` for already-minor-unit
inputs, `zero` for the additive identity, and `fromJSON` for the
`toJSON`-symmetric round-trip. Cross-currency operations raise
`MoneyError({ code: MONEY.CURRENCY_MISMATCH })` carrying both currencies on the
discriminants, and `divide(0)` raises `MoneyError({ code: MONEY.DIVISION_BY_ZERO })`.
`applyRounding` is a separately-exported helper that exposes the five
RoundingMode strategies (`half-up`, `half-down`, `half-even`, `floor`, `ceil`)
the multiply / divide methods use; consumers occasionally need it directly for
custom arithmetic.
## Features
- `bigint`-backed amount with `readonly amount` / `readonly currency` properties.
- Static constructors: `fromMajor(number | string, currency)`, `fromMinor(bigint | number, currency)`, `zero(currency)`, `fromJSON({ amount, currency })`.
- Arithmetic: `add`, `subtract`, `multiply(factor, rounding?)`, `divide(divisor, rounding?)`, `abs`, `negate`.
- Comparison: `compareTo`, `equals`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `isZero`, `isPositive`, `isNegative`.
- Conversion: `toMajor()` (lossy for very large values), `toDecimalString()` (precise), `toJSON()`, `toString()`.
- Hardened decimal-string parser — malformed input raises `MoneyError({ code: MONEY.PARSE_ERROR })` instead of silently producing `NaN`-ish bigints.
- `applyRounding(value, mode)` — standalone helper used by `multiply` / `divide` and exported for direct caller use.
## Usage: Basic Example
```ts
import { Money, formatMoney } from "@suaveplan/money";
const price = Money.fromMajor(29.99, "USD");
const tax = price.multiply(0.08); // half-up by default
const total = price.add(tax);
console.log(formatMoney(total)); // "$32.39"
console.log(total.toDecimalString()); // "32.39"
console.log(total.toString()); // "32.39 USD"
console.log(total.toJSON()); // { amount: "3239", currency: "USD" }
```
## Usage: Advanced Example
Demonstrate `bigint` precision for high-exponent cryptocurrency arithmetic plus
explicit banker's rounding for jurisdictional compliance:
```ts
import { Money, applyRounding } from "@suaveplan/money";
// 0.5 ETH split four ways — preserving wei-level precision throughout.
const reward = Money.fromMajor("0.5", "ETH"); // amount = 500_000_000_000_000_000n
const perRecipient = reward.divide(4, "half-even"); // banker's rounding
console.log(perRecipient.amount); // 125_000_000_000_000_000n
console.log(perRecipient.toDecimalString()); // "0.125000000000000000"
// Use applyRounding for a custom calculation outside the Money class.
const customScale = applyRounding(12.5, "half-even"); // 12n (round to even)
// Demonstrate that arithmetic never mutates the receiver.
const original = Money.fromMinor(100n, "USD");
const doubled = original.multiply(2);
console.log(original.amount); // 100n — unchanged
console.log(doubled.amount); // 200n
```
## API Reference
### `class Money implements MoneyLike`
| Property | Type | Description |
|---|---|---|
| `amount` | `readonly bigint` | The amount in minor units. |
| `currency` | `readonly CurrencyCode` | The currency code. |
### Static constructors
| Method | Signature | Throws |
|---|---|---|
| `Money.fromMajor` | `(value: number \| string, currency: CurrencyCode) => Money` | `MoneyError({ code: MONEY.PARSE_ERROR })` on malformed string; `MoneyError({ code: MONEY.UNKNOWN_CURRENCY })` on unknown currency. |
| `Money.fromMinor` | `(amount: bigint \| number, currency: CurrencyCode) => Money` | — |
| `Money.zero` | `(currency: CurrencyCode) => Money` | — |
| `Money.fromJSON` | `(json: { amount: string; currency: CurrencyCode }) => Money` | `SyntaxError` if `amount` is not a valid bigint string. |
### Instance methods
| Method | Signature | Throws |
|---|---|---|
| `add` | `(other: MoneyLike) => Money` | `MoneyError({ code: MONEY.CURRENCY_MISMATCH })` on cross-currency. |
| `subtract` | `(other: MoneyLike) => Money` | Same as `add`. |
| `multiply` | `(factor: number \| bigint, rounding?: RoundingMode) => Money` | — |
| `divide` | `(divisor: number \| bigint, rounding?: RoundingMode) => Money` | `MoneyError({ code: MONEY.DIVISION_BY_ZERO })` when `divisor` is `0` / `0n`. |
| `abs` / `negate` | `() => Money` | — |
| `compareTo` | `(other: MoneyLike) => -1 \| 0 \| 1` | `MoneyError({ code: MONEY.CURRENCY_MISMATCH })` on cross-currency. |
| `equals` | `(other: MoneyLike) => boolean` | — (returns `false` for cross-currency rather than throwing). |
| `greaterThan` / `lessThan` / `greaterThanOrEqual` / `lessThanOrEqual` | `(other: MoneyLike) => boolean` | Same as `compareTo`. |
| `isZero` / `isPositive` / `isNegative` | `() => boolean` | — |
| `toMajor` | `() => number` | — (lossy for very large amounts). |
| `toDecimalString` | `() => string` | — (precise; preserves trailing zeros for the currency exponent). |
| `toJSON` | `() => { amount: string; currency: CurrencyCode }` | — |
| `toString` | `() => string` | — (returns `<decimal> <currency>`). |
### `applyRounding(value, mode)`
| Argument | Type |
|---|---|
| `value` | `number` |
| `mode` | `RoundingMode` (`half-up` / `half-down` / `half-even` / `floor` / `ceil`) |
Returns `bigint`.
## Implementation Notes
`fromMajor(number)` uses `Math.round(value * Number(multiplier))` so values
with more than ~15 significant digits are lossy — pass decimal strings for
precision-critical inputs. `fromMajor(string)` runs through a hardened
parser that validates `^-?(?:\d+(?:\.\d*)?|\.\d+)$` and raises
`MoneyError({ code: MONEY.PARSE_ERROR })` on anything else; the fractional part
is padded or truncated to the currency exponent so `Money.fromMajor("10.555",
"USD")` produces `1055n` (two minor digits).
`divide(divisor)` casts both operands through `Number` before applying the
rounding mode, so callers needing exact rational arithmetic should multiply by
the inverse instead. `divide(0n)` is treated identically to `divide(0)` — both
raise `MoneyError({ code: MONEY.DIVISION_BY_ZERO })`.
`compareTo` raises on cross-currency rather than silently returning a numeric
comparison; `equals` returns `false` instead of throwing so callers can use it
in conditional checks without try/catch. The asymmetry is intentional —
ordering across currencies is meaningless, but equality is naturally `false`.
`applyRounding` handles negative values correctly: `Math.floor(-2.5) === -3` is
the floor direction, and the banker's-rounding `half-even` mode uses the
`Math.abs(diff - 0.5) < Number.EPSILON` test to detect exact halves before
applying the round-to-even rule.
`toDecimalString` short-circuits for zero-exponent currencies (JPY, KRW, CLP)
so the output never carries a redundant `.0`.

View File

@@ -0,0 +1,108 @@
# `types/common` — public type contracts
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/money` exposes a small, stable set of types that downstream packages
(`@suaveplan/currency`, `@suaveplan/payment`, the API plugins, the React UI
hooks) depend on for structural typing. This module is the single source of
truth for those types — every implementation file imports from here, and every
consumer should import the type from `@suaveplan/money` (or the subpath
`@suaveplan/money/types/common`) rather than re-deriving it.
The shapes are deliberately minimal: `MoneyLike` is just `{ readonly amount:
bigint; readonly currency: CurrencyCode }` so any object with those two fields
interoperates with `formatMoney`, the allocators, and the aggregators without
needing to be an actual `Money` instance. `CurrencyInfo` carries the metadata
needed for formatting and arithmetic (exponent, symbol, name) plus optional
flags for virtual / game currencies. `FormatOptions` exposes every knob the
formatter accepts; `AllocationResult` is the contract every allocator returns.
## Features
- `MoneyLike` — duck-typed interop shape (`{ readonly amount: bigint, readonly currency: CurrencyCode }`).
- `CurrencyInfo` — ISO 4217 metadata + optional `virtual` / `issuerId` for game currencies.
- `FormatOptions` — every formatter knob: locale, symbol/code mode, separator overrides, trailing-zero control.
- `AllocationResult` — every allocator's return shape; `shares` array + `remainder` (always `0n` today).
- `RoundingMode` — literal-string union of the five rounding modes used by `multiply`, `divide`, and `applyRounding`.
- `MoneyOptions` — constructor-options shape for `Money`.
- `CurrencyCode` — opaque string alias clarifying ISO 4217 intent at call sites.
## Usage: Basic Example
```ts
import type { MoneyLike, FormatOptions } from "@suaveplan/money";
import { formatMoney } from "@suaveplan/money";
function renderPrice(value: MoneyLike, locale: string): string {
const opts: FormatOptions = { locale, showCode: true };
return formatMoney(value, opts);
}
renderPrice({ amount: 1050n, currency: "USD" }, "en-US"); // "10.50 USD"
```
## Usage: Advanced Example
Use the structural `MoneyLike` shape to interoperate with foreign data without
having to construct a `Money` instance — useful when payload validation has
already produced trusted `{ amount, currency }` records:
```ts
import type { MoneyLike, AllocationResult } from "@suaveplan/money";
import { allocateByRatios, sumMoney } from "@suaveplan/money";
interface Invoice {
total: MoneyLike;
splits: readonly { partyId: string; ratio: number }[];
}
function distribute(invoice: Invoice): Map<string, MoneyLike> {
const result: AllocationResult = allocateByRatios(
invoice.total,
invoice.splits.map((s) => s.ratio)
);
// sumMoney always equals the input — invariant guaranteed by the allocator.
if (sumMoney(result.shares).amount !== invoice.total.amount) {
throw new Error("allocator broke its invariant");
}
return new Map(
invoice.splits.map((s, i) => [s.partyId, result.shares[i] as MoneyLike])
);
}
```
## API Reference
| Type | Shape |
|---|---|
| `CurrencyCode` | `string` (opaque alias) |
| `RoundingMode` | `"half-up" \| "half-down" \| "half-even" \| "floor" \| "ceil"` |
| `MoneyOptions` | `{ amount: bigint; currency: CurrencyCode }` |
| `CurrencyInfo` | `{ code; exponent; symbol; name; virtual?; issuerId? }` |
| `FormatOptions` | `{ locale?; showSymbol?; showCode?; decimalSeparator?; thousandsSeparator?; trailingZeros? }` |
| `MoneyLike` | `{ readonly amount: bigint; readonly currency: CurrencyCode }` |
| `AllocationResult` | `{ shares: readonly MoneyLike[]; remainder: bigint }` |
All types are re-exported from the package root and from the
`@suaveplan/money/types/common` subpath.
## Implementation Notes
`CurrencyCode` is intentionally `string` rather than a literal union because the
registry is open — callers may `registerCurrency({ code: "GEM", ... })` for
game / loyalty / experimental tokens. A closed union would force every consumer
to use a type-assertion when accepting user-registered codes, defeating the
ergonomic purpose of the alias.
`AllocationResult.remainder` is always `0n` in the current allocators (every
allocator distributes its remainder into `shares`). The field is retained as a
forward-compatible escape hatch for future allocators (e.g. greedy allocators
that surface their remainder for the caller to handle out-of-band).
`MoneyLike` deliberately omits methods — it is the structural-typing contract,
not the implementation surface. `Money implements MoneyLike` satisfies the
constraint at no runtime cost.