first commit
This commit is contained in:
25
docs/money/finance/README.md
Normal file
25
docs/money/finance/README.md
Normal 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 -->
|
||||
1
docs/money/finance/_intro.md
Normal file
1
docs/money/finance/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Currency conversion, cryptocurrency utilities, and exchange-rate providers.
|
||||
147
docs/money/finance/cryptocurrency/README.md
Normal file
147
docs/money/finance/cryptocurrency/README.md
Normal 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
|
||||
|
||||
84
docs/money/finance/cryptocurrency/src/address/validator.md
Normal file
84
docs/money/finance/cryptocurrency/src/address/validator.md
Normal 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."
|
||||
62
docs/money/finance/cryptocurrency/src/chains/chain-data.md
Normal file
62
docs/money/finance/cryptocurrency/src/chains/chain-data.md
Normal 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.
|
||||
126
docs/money/finance/cryptocurrency/src/chains/chain-registry.md
Normal file
126
docs/money/finance/cryptocurrency/src/chains/chain-registry.md
Normal 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:*`.
|
||||
71
docs/money/finance/cryptocurrency/src/chains/registry.md
Normal file
71
docs/money/finance/cryptocurrency/src/chains/registry.md
Normal 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.
|
||||
@@ -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.
|
||||
119
docs/money/finance/cryptocurrency/src/gas/gas-estimator.md
Normal file
119
docs/money/finance/cryptocurrency/src/gas/gas-estimator.md
Normal 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.
|
||||
64
docs/money/finance/cryptocurrency/src/tokens/token-data.md
Normal file
64
docs/money/finance/cryptocurrency/src/tokens/token-data.md
Normal 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.
|
||||
@@ -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`.
|
||||
108
docs/money/finance/cryptocurrency/src/transaction/tx-decoder.md
Normal file
108
docs/money/finance/cryptocurrency/src/transaction/tx-decoder.md
Normal 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.
|
||||
95
docs/money/finance/cryptocurrency/src/types/crypto.types.md
Normal file
95
docs/money/finance/cryptocurrency/src/types/crypto.types.md
Normal 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.
|
||||
97
docs/money/finance/cryptocurrency/src/units/converter.md
Normal file
97
docs/money/finance/cryptocurrency/src/units/converter.md
Normal 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.
|
||||
84
docs/money/finance/currency-provider-binance/README.md
Normal file
84
docs/money/finance/currency-provider-binance/README.md
Normal 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.
|
||||
|
||||
@@ -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
|
||||
79
docs/money/finance/currency-provider-coingecko/README.md
Normal file
79
docs/money/finance/currency-provider-coingecko/README.md
Normal 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.
|
||||
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
91
docs/money/finance/currency-provider-coinmarketcap/README.md
Normal file
91
docs/money/finance/currency-provider-coinmarketcap/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
75
docs/money/finance/currency-provider-cryptocompare/README.md
Normal file
75
docs/money/finance/currency-provider-cryptocompare/README.md
Normal 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).
|
||||
|
||||
@@ -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.
|
||||
66
docs/money/finance/currency-provider-ecb/README.md
Normal file
66
docs/money/finance/currency-provider-ecb/README.md
Normal 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.
|
||||
|
||||
143
docs/money/finance/currency-provider-ecb/src/ecb/ecb.md
Normal file
143
docs/money/finance/currency-provider-ecb/src/ecb/ecb.md
Normal 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.
|
||||
89
docs/money/finance/currency-provider-fixer/README.md
Normal file
89
docs/money/finance/currency-provider-fixer/README.md
Normal 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.
|
||||
|
||||
154
docs/money/finance/currency-provider-fixer/src/fixer/fixer.md
Normal file
154
docs/money/finance/currency-provider-fixer/src/fixer/fixer.md
Normal 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`.
|
||||
87
docs/money/finance/currency-provider-kraken/README.md
Normal file
87
docs/money/finance/currency-provider-kraken/README.md
Normal 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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
92
docs/money/finance/currency-provider-virtual/README.md
Normal file
92
docs/money/finance/currency-provider-virtual/README.md
Normal 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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
126
docs/money/finance/currency-providers/README.md
Normal file
126
docs/money/finance/currency-providers/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
132
docs/money/finance/currency-providers/src/factory/factory.md
Normal file
132
docs/money/finance/currency-providers/src/factory/factory.md
Normal 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`.
|
||||
155
docs/money/finance/currency-providers/src/http/http.md
Normal file
155
docs/money/finance/currency-providers/src/http/http.md
Normal 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.
|
||||
128
docs/money/finance/currency-providers/src/registry/registry.md
Normal file
128
docs/money/finance/currency-providers/src/registry/registry.md
Normal 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.
|
||||
98
docs/money/finance/currency-providers/src/types/configs.md
Normal file
98
docs/money/finance/currency-providers/src/types/configs.md
Normal 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.
|
||||
105
docs/money/finance/currency/README.md
Normal file
105
docs/money/finance/currency/README.md
Normal 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.
|
||||
|
||||
185
docs/money/finance/currency/src/converter/converter.md
Normal file
185
docs/money/finance/currency/src/converter/converter.md
Normal 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.
|
||||
105
docs/money/finance/currency/src/error-codes/error-codes.md
Normal file
105
docs/money/finance/currency/src/error-codes/error-codes.md
Normal 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.
|
||||
108
docs/money/finance/currency/src/provider/provider.md
Normal file
108
docs/money/finance/currency/src/provider/provider.md
Normal 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.
|
||||
91
docs/money/finance/currency/src/rate-cache/rate-cache.md
Normal file
91
docs/money/finance/currency/src/rate-cache/rate-cache.md
Normal 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.
|
||||
106
docs/money/finance/currency/src/schemas/schemas.md
Normal file
106
docs/money/finance/currency/src/schemas/schemas.md
Normal 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.
|
||||
112
docs/money/finance/currency/src/types/common.md
Normal file
112
docs/money/finance/currency/src/types/common.md
Normal 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.
|
||||
120
docs/money/finance/money/README.md
Normal file
120
docs/money/finance/money/README.md
Normal 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
|
||||
|
||||
144
docs/money/finance/money/src/allocator/allocator.md
Normal file
144
docs/money/finance/money/src/allocator/allocator.md
Normal 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.
|
||||
119
docs/money/finance/money/src/currencies/currencies.md
Normal file
119
docs/money/finance/money/src/currencies/currencies.md
Normal 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.
|
||||
107
docs/money/finance/money/src/error-codes/error-codes.md
Normal file
107
docs/money/finance/money/src/error-codes/error-codes.md
Normal 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.
|
||||
144
docs/money/finance/money/src/formatter/formatter.md
Normal file
144
docs/money/finance/money/src/formatter/formatter.md
Normal 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`).
|
||||
150
docs/money/finance/money/src/money/money.md
Normal file
150
docs/money/finance/money/src/money/money.md
Normal 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`.
|
||||
108
docs/money/finance/money/src/types/common.md
Normal file
108
docs/money/finance/money/src/types/common.md
Normal 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.
|
||||
Reference in New Issue
Block a user