Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

43
packages/money/README.md Normal file
View File

@@ -0,0 +1,43 @@
# `money`
Payments, currencies, cryptocurrency, and financial primitives.
<!-- begin:auto-docs -->
## Subcategories
2 subcategories, 20 packages.
### `finance`
Currency conversion, cryptocurrency utilities, and exchange-rate providers.
| 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 / funct… |
| [`@suaveplan/currency`](./currency/README.md) | Universal currency conversion engine — pluggable `CurrencyProvider` interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup, primar… |
| [`@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… |
| [`@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… |
| [`@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… |
| [`@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 `CurrencyProvid… |
| [`@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 upst… |
| [`@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… |
### `payment`
Payment intent lifecycle, provider adapters, and fraud detection.
| Package | Description |
|---|---|
| [`@suaveplan/payment`](./payment/README.md) | Universal payment intent lifecycle, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry orchestrati… |
| [`@suaveplan/payment-provider-adyen`](./payment-provider-adyen/README.md) | Universal **Adyen Checkout API v71 adapter** for `@suaveplan/payment`. Implements the `PaymentProvider` interface via the slim `@suaveplan/payment-provide… |
| [`@suaveplan/payment-provider-mollie`](./payment-provider-mollie/README.md) | Universal Mollie Payments API v2 adapter for `@suaveplan/payment`. Sibling provider package on the slim `@suaveplan/payment-providers` core: implements `P… |
| [`@suaveplan/payment-provider-paypal`](./payment-provider-paypal/README.md) | PayPal sibling adapter for the slim `@suaveplan/payment-providers` foundation. Ships `PayPalProvider` — a `PaymentProvider` implementation against the PayPal O… |
| [`@suaveplan/payment-provider-square`](./payment-provider-square/README.md) | Universal Square Payments API adapter for `@suaveplan/payment`. Ships the `SquareProvider` class (implements the structural `PaymentProvider` contract wit… |
| [`@suaveplan/payment-provider-stripe`](./payment-provider-stripe/README.md) | Universal Stripe adapter for `@suaveplan/payment`. Implements `PaymentProvider` against the Stripe REST API using the slim `@suaveplan/payment-providers`… |
| [`@suaveplan/payment-providers`](./payment-providers/README.md) | Universal **foundation** for the `@suaveplan/payment-provider-*` sibling-package family. This package ships only the shared abstractions every concrete ad… |
<!-- end:auto-docs -->

3
packages/money/_intro.md Normal file
View File

@@ -0,0 +1,3 @@
# `money`
Payments, currencies, cryptocurrency, and financial primitives.

View File

@@ -0,0 +1,25 @@
# `finance`
Currency conversion, cryptocurrency utilities, and exchange-rate providers.
<!-- begin:auto-docs -->
## Packages in `finance`
13 packages.
| Package | Description |
|---|---|
| [`@suaveplan/cryptocurrency`](./cryptocurrency/README.md) | Universal cryptocurrency utilities for wallet address validation, unit conversion, chain registry, gas estimation, ERC-20 token registry, calldata / funct… |
| [`@suaveplan/currency`](./currency/README.md) | Universal currency conversion engine — pluggable `CurrencyProvider` interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup, primar… |
| [`@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… |
| [`@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… |
| [`@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… |
| [`@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 `CurrencyProvid… |
| [`@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 upst… |
| [`@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… |
<!-- end:auto-docs -->

View File

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

View File

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

View File

@@ -0,0 +1,170 @@
{
"name": "@suaveplan/cryptocurrency",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./address/validator": {
"import": {
"types": "./dist/address/validator.d.ts",
"default": "./dist/address/validator.js"
},
"require": {
"types": "./dist/address/validator.d.ts",
"default": "./dist/address/validator.js"
}
},
"./chains/chain-data": {
"import": {
"types": "./dist/chains/chain-data.d.ts",
"default": "./dist/chains/chain-data.js"
},
"require": {
"types": "./dist/chains/chain-data.d.ts",
"default": "./dist/chains/chain-data.js"
}
},
"./chains/chain-registry": {
"import": {
"types": "./dist/chains/chain-registry.d.ts",
"default": "./dist/chains/chain-registry.js"
},
"require": {
"types": "./dist/chains/chain-registry.d.ts",
"default": "./dist/chains/chain-registry.js"
}
},
"./chains/registry": {
"import": {
"types": "./dist/chains/registry.d.ts",
"default": "./dist/chains/registry.js"
},
"require": {
"types": "./dist/chains/registry.d.ts",
"default": "./dist/chains/registry.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
},
"require": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./gas/gas-estimator": {
"import": {
"types": "./dist/gas/gas-estimator.d.ts",
"default": "./dist/gas/gas-estimator.js"
},
"require": {
"types": "./dist/gas/gas-estimator.d.ts",
"default": "./dist/gas/gas-estimator.js"
}
},
"./tokens/token-data": {
"import": {
"types": "./dist/tokens/token-data.d.ts",
"default": "./dist/tokens/token-data.js"
},
"require": {
"types": "./dist/tokens/token-data.d.ts",
"default": "./dist/tokens/token-data.js"
}
},
"./tokens/token-registry": {
"import": {
"types": "./dist/tokens/token-registry.d.ts",
"default": "./dist/tokens/token-registry.js"
},
"require": {
"types": "./dist/tokens/token-registry.d.ts",
"default": "./dist/tokens/token-registry.js"
}
},
"./transaction/tx-decoder": {
"import": {
"types": "./dist/transaction/tx-decoder.d.ts",
"default": "./dist/transaction/tx-decoder.js"
},
"require": {
"types": "./dist/transaction/tx-decoder.d.ts",
"default": "./dist/transaction/tx-decoder.js"
}
},
"./types/crypto.types": {
"import": {
"types": "./dist/types/crypto.types.d.ts",
"default": "./dist/types/crypto.types.js"
},
"require": {
"types": "./dist/types/crypto.types.d.ts",
"default": "./dist/types/crypto.types.js"
}
},
"./units/converter": {
"import": {
"types": "./dist/units/converter.d.ts",
"default": "./dist/units/converter.js"
},
"require": {
"types": "./dist/units/converter.d.ts",
"default": "./dist/units/converter.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,5 @@
export {
validateAddress,
validateAddressForChain,
validateTxHash,
} from "./validator.js";

View File

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

View File

@@ -0,0 +1,231 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
validateAddress,
validateAddressForChain,
validateTxHash,
} from "./validator.js";
describe("validateAddress", () => {
it("validates EVM address", { timeout: 5000 }, () => {
const result = validateAddress(
"0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28"
);
expect(result.valid).toBe(true);
expect(result.chains).toContain("ethereum");
expect(result.chains).toContain("polygon");
expect(result.chains).toContain("arbitrum");
expect(result.chains).toContain("optimism");
expect(result.chains).toContain("avalanche");
expect(result.chains).toContain("bsc");
expect(result.chains).toContain("base");
expect(result.normalized).toBeDefined();
});
it("validates Bitcoin legacy address (1-prefix)", { timeout: 5000 }, () => {
const result = validateAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa");
expect(result.valid).toBe(true);
expect(result.chains).toContain("bitcoin");
});
it("validates Bitcoin P2SH address (3-prefix)", { timeout: 5000 }, () => {
const result = validateAddress("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy");
expect(result.valid).toBe(true);
expect(result.chains).toContain("bitcoin");
});
it("validates Bitcoin Segwit address (bc1)", { timeout: 5000 }, () => {
const result = validateAddress(
"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
);
expect(result.valid).toBe(true);
expect(result.chains).toContain("bitcoin");
});
it("validates Litecoin legacy address", { timeout: 5000 }, () => {
const result = validateAddress("LMVGCcrCKJsk9w26L4xkHCMm2Y2bRkj3rL");
expect(result.valid).toBe(true);
expect(result.chains).toContain("litecoin");
});
it("validates Litecoin Segwit address (ltc1)", { timeout: 5000 }, () => {
const result = validateAddress(
"ltc1qar0srrr7xfkvy5l643lydnw9re59gtzzkqrd0c"
);
expect(result.valid).toBe(true);
expect(result.chains).toContain("litecoin");
});
it("validates Solana address", { timeout: 5000 }, () => {
const result = validateAddress(
"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"
);
expect(result.valid).toBe(true);
expect(result.chains).toContain("solana");
});
it("validates TRON address", { timeout: 5000 }, () => {
const result = validateAddress("TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW");
expect(result.valid).toBe(true);
expect(result.chains).toContain("tron");
});
it("validates Dogecoin address", { timeout: 5000 }, () => {
const result = validateAddress("DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L");
expect(result.valid).toBe(true);
expect(result.chains).toContain("dogecoin");
});
it("validates Cardano address", { timeout: 5000 }, () => {
const result = validateAddress(
"addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp"
);
expect(result.valid).toBe(true);
expect(result.chains).toContain("cardano");
});
it("validates Cosmos address", { timeout: 5000 }, () => {
const result = validateAddress(
"cosmos1hsk6jryyqjfhp5dhc55tc9jtckygx0eph6dd02"
);
expect(result.valid).toBe(true);
expect(result.chains).toContain("cosmos");
});
it("rejects empty string", { timeout: 5000 }, () => {
const result = validateAddress("");
expect(result.valid).toBe(false);
expect(result.error).toBe("Address must be a non-empty string");
});
it("rejects invalid address", { timeout: 5000 }, () => {
const result = validateAddress("not-an-address");
expect(result.valid).toBe(false);
expect(result.error).toBe("Address does not match any known format");
});
it("rejects non-string input", { timeout: 5000 }, () => {
const result = validateAddress(null as unknown as string);
expect(result.valid).toBe(false);
});
it("trims whitespace", { timeout: 5000 }, () => {
const result = validateAddress(
" 0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28 "
);
expect(result.valid).toBe(true);
});
it("checksums EVM normalized output with positional algorithm", {
timeout: 5000,
}, () => {
const result = validateAddress(
"0xabcdef1234567890abcdef1234567890abcdef12"
);
expect(result.valid).toBe(true);
// positional: even-index lowered, odd-index uppered alphabetic chars
// 0xabcdef1234567890abcdef1234567890abcdef12
// index 0 = a => uppercase 'A', index 1 = b => 'b', etc.
expect(result.normalized).toBe(
"0xAbCdEf1234567890AbCdEf1234567890AbCdEf12"
);
});
it("non-EVM matches return trimmed input as normalized", {
timeout: 5000,
}, () => {
// For non-EVM chains the normalized value is the trimmed input —
// no checksum is applied.
const result = validateAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa");
expect(result.normalized).toBe("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa");
});
});
describe("validateAddressForChain", () => {
it("validates EVM address for ethereum", { timeout: 5000 }, () => {
const result = validateAddressForChain(
"0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28",
"ethereum"
);
expect(result.valid).toBe(true);
expect(result.chains).toEqual(["ethereum"]);
});
it("rejects EVM address for bitcoin", { timeout: 5000 }, () => {
const result = validateAddressForChain(
"0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28",
"bitcoin"
);
expect(result.valid).toBe(false);
expect(result.error).toContain("not valid for bitcoin");
});
it("rejects invalid address for any chain", { timeout: 5000 }, () => {
const result = validateAddressForChain("invalid", "ethereum");
expect(result.valid).toBe(false);
});
});
describe("validateTxHash", () => {
it("validates EVM tx hash (0x prefix)", { timeout: 5000 }, () => {
const hash = `0x${"a".repeat(64)}`;
const result = validateTxHash(hash);
expect(result.valid).toBe(true);
expect(result.chains).toContain("ethereum");
});
it("validates Bitcoin tx hash (no prefix)", { timeout: 5000 }, () => {
const hash = "a".repeat(64);
const result = validateTxHash(hash);
expect(result.valid).toBe(true);
expect(result.chains).toContain("bitcoin");
});
it("validates Solana tx signature", { timeout: 5000 }, () => {
// 88 chars of valid base58 (no 0, O, I, l).
const sig = "5VERv8NMhJnPVv7m2LaJmpGqKqTFTJbLFzDG".padEnd(88, "A");
const result = validateTxHash(sig);
expect(result.valid).toBe(true);
expect(result.chains).toContain("solana");
});
it("rejects empty hash", { timeout: 5000 }, () => {
const result = validateTxHash("");
expect(result.valid).toBe(false);
expect(result.error).toBe("Hash must be a non-empty string");
});
it("rejects invalid hash", { timeout: 5000 }, () => {
const result = validateTxHash("not-a-hash");
expect(result.valid).toBe(false);
expect(result.error).toBe("Hash does not match any known format");
});
it("rejects non-string input", { timeout: 5000 }, () => {
const result = validateTxHash(null as unknown as string);
expect(result.valid).toBe(false);
});
it("trims hash whitespace", { timeout: 5000 }, () => {
const result = validateTxHash(` 0x${"a".repeat(64)} `);
expect(result.valid).toBe(true);
});
});
describe("validator source", () => {
it("validator source has no node/bun imports (tier purity)", {
timeout: 5000,
}, () => {
// Production source must be universal (no node:/bun:/fs/path/etc.).
// Read the implementation source (not this test file) and assert.
const src = readFileSync(
new URL("./validator.ts", import.meta.url),
"utf8"
);
expect(src).not.toMatch(/from ['"]node:/);
expect(src).not.toMatch(/from ['"]bun:/);
expect(src).not.toMatch(
/from ['"](fs|path|stream|net|http|crypto)['"]/
);
});
});

View File

@@ -0,0 +1,212 @@
import type {
AddressValidation,
ChainId,
TxHashValidation,
} from "../types/crypto.types.js";
interface AddressPattern {
readonly regex: RegExp;
readonly chains: readonly ChainId[];
}
/** Regex patterns for address validation by chain type. */
const ADDRESS_PATTERNS: Readonly<Record<string, AddressPattern>> = {
evmHex: {
regex: /^0x[0-9a-fA-F]{40}$/,
chains: [
"ethereum",
"polygon",
"arbitrum",
"optimism",
"avalanche",
"bsc",
"base",
],
},
bitcoinLegacy: {
regex: /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/,
chains: ["bitcoin"],
},
bitcoinSegwit: {
regex: /^bc1[a-z0-9]{39,59}$/i,
chains: ["bitcoin"],
},
litecoinLegacy: {
regex: /^[LM3][a-km-zA-HJ-NP-Z1-9]{25,34}$/,
chains: ["litecoin"],
},
litecoinSegwit: {
regex: /^ltc1[a-z0-9]{39,59}$/i,
chains: ["litecoin"],
},
solana: {
regex: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
chains: ["solana"],
},
tron: {
regex: /^T[a-km-zA-HJ-NP-Z1-9]{33}$/,
chains: ["tron"],
},
dogecoin: {
regex: /^D[5-9A-HJ-NP-U][a-km-zA-HJ-NP-Z1-9]{24,33}$/,
chains: ["dogecoin"],
},
cardano: {
regex: /^addr1[a-z0-9]{58,}$/,
chains: ["cardano"],
},
cosmos: {
regex: /^cosmos1[a-z0-9]{38}$/,
chains: ["cosmos"],
},
polkadot: {
regex: /^1[a-km-zA-HJ-NP-Z1-9]{47}$/,
chains: ["polkadot"],
},
};
const EVM_TX_HASH = /^0x[0-9a-fA-F]{64}$/;
const UTXO_TX_HASH = /^[0-9a-fA-F]{64}$/;
const SOLANA_TX_SIG = /^[1-9A-HJ-NP-Za-km-z]{87,88}$/;
const EVM_TX_CHAINS: readonly ChainId[] = [
"ethereum",
"polygon",
"arbitrum",
"optimism",
"avalanche",
"bsc",
"base",
];
const UTXO_TX_CHAINS: readonly ChainId[] = ["bitcoin", "litecoin", "dogecoin"];
const SOLANA_TX_CHAINS: readonly ChainId[] = ["solana"];
/**
* Validate a cryptocurrency address and detect which chain(s) it belongs to.
*
* For EVM-matching input the returned `normalized` is the positional EIP-55
* checksum of the address — note that this is a simplified positional
* algorithm preserved from the upstream library, not the canonical
* keccak256-based EIP-55 algorithm. Callers that need RFC-correct
* checksumming should re-checksum the returned address with a keccak256
* implementation.
*/
export function validateAddress(address: string): AddressValidation {
if (!address || typeof address !== "string") {
return {
valid: false,
chains: [],
error: "Address must be a non-empty string",
};
}
const trimmed = address.trim();
const matchedChains: ChainId[] = [];
for (const { regex, chains } of Object.values(ADDRESS_PATTERNS)) {
if (regex.test(trimmed)) {
matchedChains.push(...chains);
}
}
if (matchedChains.length === 0) {
return {
valid: false,
chains: [],
error: "Address does not match any known format",
};
}
const normalized = matchedChains.includes("ethereum")
? checksumEthAddress(trimmed)
: trimmed;
return { valid: true, normalized, chains: matchedChains };
}
/**
* Validate a cryptocurrency address for a specific chain.
*
* Returns the result of `validateAddress` with `chains` narrowed to the
* requested id on success, or the original failure when validation fails
* outright, or a `not valid for <chain>` failure when the address validates
* but doesn't include the requested id in its matched set.
*/
export function validateAddressForChain(
address: string,
chainId: ChainId
): AddressValidation {
const result = validateAddress(address);
if (!result.valid) return result;
if (!result.chains.includes(chainId)) {
return {
valid: false,
chains: result.chains,
error: `Address is not valid for ${chainId}`,
};
}
return { ...result, chains: [chainId] };
}
/**
* Validate a transaction hash and detect its possible chain(s).
*
* Recognises EVM hashes (`0x` + 64 hex), UTXO hashes (64 hex, no prefix),
* and Solana signatures (base58, 87-88 chars).
*/
export function validateTxHash(hash: string): TxHashValidation {
if (!hash || typeof hash !== "string") {
return {
valid: false,
chains: [],
error: "Hash must be a non-empty string",
};
}
const trimmed = hash.trim();
if (EVM_TX_HASH.test(trimmed)) {
return { valid: true, chains: [...EVM_TX_CHAINS] };
}
if (UTXO_TX_HASH.test(trimmed)) {
return { valid: true, chains: [...UTXO_TX_CHAINS] };
}
if (SOLANA_TX_SIG.test(trimmed)) {
return { valid: true, chains: [...SOLANA_TX_CHAINS] };
}
return {
valid: false,
chains: [],
error: "Hash does not match any known format",
};
}
/**
* Compute the simplified positional EIP-55 checksum for an EVM address.
*
* This is NOT the canonical EIP-55 algorithm (which requires keccak256).
* Behaviour is preserved bit-for-bit from the upstream library so any
* consumer depending on its checksum output keeps working. A future
* change will swap to keccak256 once `@suaveplan/crypto-hash` is wired in.
*/
function checksumEthAddress(address: string): string {
// Caller (`validateAddress`) only invokes this when the EVM hex regex
// has already matched, so the `0x` prefix is guaranteed. We slice past
// it unconditionally.
const lower = address.slice(2).toLowerCase();
let checksummed = "0x";
for (let i = 0; i < lower.length; i++) {
const char = lower[i] as string;
if (/[a-f]/.test(char)) {
checksummed += i % 2 === 0 ? char.toUpperCase() : char;
} else {
checksummed += char;
}
}
return checksummed;
}

View File

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

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ChainId } from "../types/crypto.types.js";
import { CHAINS } from "./chain-data.js";
const EXPECTED_IDS: ReadonlyArray<ChainId> = [
"bitcoin",
"ethereum",
"solana",
"polygon",
"arbitrum",
"optimism",
"avalanche",
"bsc",
"base",
"tron",
"litecoin",
"dogecoin",
"cardano",
"polkadot",
"cosmos",
];
describe("CHAINS", () => {
it("CHAINS contains all 15 mainnet entries", { timeout: 5000 }, () => {
const keys = Object.keys(CHAINS).sort();
expect(keys).toEqual([...EXPECTED_IDS].sort());
for (const id of EXPECTED_IDS) {
expect(CHAINS[id].id).toBe(id);
}
});
it("EVM chains carry the canonical mainnet evmChainId", {
timeout: 5000,
}, () => {
expect(CHAINS.ethereum.evmChainId).toBe(1);
expect(CHAINS.bsc.evmChainId).toBe(56);
expect(CHAINS.polygon.evmChainId).toBe(137);
expect(CHAINS.optimism.evmChainId).toBe(10);
expect(CHAINS.base.evmChainId).toBe(8453);
expect(CHAINS.arbitrum.evmChainId).toBe(42161);
expect(CHAINS.avalanche.evmChainId).toBe(43114);
});
it("non-EVM chains omit evmChainId", { timeout: 5000 }, () => {
expect(CHAINS.bitcoin.evmChainId).toBeUndefined();
expect(CHAINS.solana.evmChainId).toBeUndefined();
expect(CHAINS.tron.evmChainId).toBeUndefined();
expect(CHAINS.litecoin.evmChainId).toBeUndefined();
expect(CHAINS.dogecoin.evmChainId).toBeUndefined();
expect(CHAINS.cardano.evmChainId).toBeUndefined();
expect(CHAINS.polkadot.evmChainId).toBeUndefined();
expect(CHAINS.cosmos.evmChainId).toBeUndefined();
});
it("every entry has required fields populated", { timeout: 5000 }, () => {
for (const id of EXPECTED_IDS) {
const c = CHAINS[id];
expect(c.name).toBeTruthy();
expect(c.nativeSymbol).toBeTruthy();
expect(c.addressType).toBeTruthy();
expect(c.explorerUrl).toMatch(/^https?:\/\//);
expect(c.decimals).toBeGreaterThanOrEqual(0);
expect(c.testnet).toBe(false);
}
});
});

View File

@@ -0,0 +1,154 @@
import type { ChainId, ChainInfo } from "../types/crypto.types.js";
/**
* Built-in chain registry data for the 15 supported mainnet networks.
*
* Every entry conforms to `ChainInfo`. EVM-compatible chains carry a
* canonical `evmChainId`; non-EVM chains omit the field. `testnet` is
* always `false` for the built-in set — testnet entries land in a follow-up
* change (`add-cryptocurrency-testnets`).
*/
export const CHAINS: Record<ChainId, ChainInfo> = {
bitcoin: {
id: "bitcoin",
name: "Bitcoin",
nativeSymbol: "BTC",
addressType: "bech32",
explorerUrl: "https://blockchair.com/bitcoin",
decimals: 8,
testnet: false,
},
ethereum: {
id: "ethereum",
name: "Ethereum",
nativeSymbol: "ETH",
evmChainId: 1,
addressType: "hex",
explorerUrl: "https://etherscan.io",
decimals: 18,
testnet: false,
},
solana: {
id: "solana",
name: "Solana",
nativeSymbol: "SOL",
addressType: "base58",
explorerUrl: "https://solscan.io",
decimals: 9,
testnet: false,
},
polygon: {
id: "polygon",
name: "Polygon",
nativeSymbol: "POL",
evmChainId: 137,
addressType: "hex",
explorerUrl: "https://polygonscan.com",
decimals: 18,
testnet: false,
},
arbitrum: {
id: "arbitrum",
name: "Arbitrum One",
nativeSymbol: "ETH",
evmChainId: 42161,
addressType: "hex",
explorerUrl: "https://arbiscan.io",
decimals: 18,
testnet: false,
},
optimism: {
id: "optimism",
name: "Optimism",
nativeSymbol: "ETH",
evmChainId: 10,
addressType: "hex",
explorerUrl: "https://optimistic.etherscan.io",
decimals: 18,
testnet: false,
},
avalanche: {
id: "avalanche",
name: "Avalanche C-Chain",
nativeSymbol: "AVAX",
evmChainId: 43114,
addressType: "hex",
explorerUrl: "https://snowtrace.io",
decimals: 18,
testnet: false,
},
bsc: {
id: "bsc",
name: "BNB Smart Chain",
nativeSymbol: "BNB",
evmChainId: 56,
addressType: "hex",
explorerUrl: "https://bscscan.com",
decimals: 18,
testnet: false,
},
base: {
id: "base",
name: "Base",
nativeSymbol: "ETH",
evmChainId: 8453,
addressType: "hex",
explorerUrl: "https://basescan.org",
decimals: 18,
testnet: false,
},
tron: {
id: "tron",
name: "TRON",
nativeSymbol: "TRX",
addressType: "base58",
explorerUrl: "https://tronscan.org",
decimals: 6,
testnet: false,
},
litecoin: {
id: "litecoin",
name: "Litecoin",
nativeSymbol: "LTC",
addressType: "bech32",
explorerUrl: "https://blockchair.com/litecoin",
decimals: 8,
testnet: false,
},
dogecoin: {
id: "dogecoin",
name: "Dogecoin",
nativeSymbol: "DOGE",
addressType: "base58",
explorerUrl: "https://blockchair.com/dogecoin",
decimals: 8,
testnet: false,
},
cardano: {
id: "cardano",
name: "Cardano",
nativeSymbol: "ADA",
addressType: "bech32",
explorerUrl: "https://cardanoscan.io",
decimals: 6,
testnet: false,
},
polkadot: {
id: "polkadot",
name: "Polkadot",
nativeSymbol: "DOT",
addressType: "ss58",
explorerUrl: "https://polkadot.subscan.io",
decimals: 10,
testnet: false,
},
cosmos: {
id: "cosmos",
name: "Cosmos Hub",
nativeSymbol: "ATOM",
addressType: "bech32",
explorerUrl: "https://www.mintscan.io/cosmos",
decimals: 6,
testnet: false,
},
};

View File

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

View File

@@ -0,0 +1,252 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
} from "../error-codes/error-codes.js";
import type { ChainId, ChainInfo } from "../types/crypto.types.js";
import { ChainRegistry, MemoryChainRegistry } from "./chain-registry.js";
interface RecordedEvent {
readonly level: "debug" | "info" | "warn" | "error";
readonly message: string;
readonly context: Readonly<Record<string, unknown>> | undefined;
}
function makeRecordingLogger(): {
logger: LoggerLike;
events: RecordedEvent[];
} {
const events: RecordedEvent[] = [];
const logger: LoggerLike = {
debug(message, context) {
events.push({ level: "debug", message, context });
},
info(message, context) {
events.push({ level: "info", message, context });
},
warn(message, context) {
events.push({ level: "warn", message, context });
},
error(message, context) {
events.push({ level: "error", message, context });
},
};
return { logger, events };
}
describe("MemoryChainRegistry", () => {
it("MemoryChainRegistry loads default chains on construction", {
timeout: 30000,
}, async () => {
const registry = new MemoryChainRegistry();
const eth = await registry.getChain("ethereum");
expect(eth).toBeDefined();
expect(eth?.name).toBe("Ethereum");
});
it("MemoryChainRegistry loads custom initial chains", {
timeout: 30000,
}, async () => {
const custom: ChainInfo = {
id: "ethereum",
name: "Custom ETH",
nativeSymbol: "ETH",
evmChainId: 1,
addressType: "hex",
explorerUrl: "https://custom.io",
decimals: 18,
testnet: false,
};
const registry = new MemoryChainRegistry([custom]);
const all = await registry.getAllChains();
expect(all).toHaveLength(1);
expect(all[0]?.name).toBe("Custom ETH");
});
it("returns all chains", { timeout: 30000 }, async () => {
const registry = new MemoryChainRegistry();
const all = await registry.getAllChains();
expect(all.length).toBe(15);
});
it("finds chain by EVM ID", { timeout: 30000 }, async () => {
const registry = new MemoryChainRegistry();
const polygon = await registry.getChainByEvmId(137);
expect(polygon?.id).toBe("polygon");
});
it("returns undefined for unknown EVM ID", { timeout: 30000 }, async () => {
const registry = new MemoryChainRegistry();
const result = await registry.getChainByEvmId(99999);
expect(result).toBeUndefined();
});
it("returns undefined for unknown chain ID", {
timeout: 30000,
}, async () => {
const registry = new MemoryChainRegistry();
const result = await registry.getChain("unknown" as ChainId);
expect(result).toBeUndefined();
});
it("registers a new chain", { timeout: 30000 }, async () => {
const registry = new MemoryChainRegistry();
const newChain: ChainInfo = {
id: "ethereum",
name: "Sepolia Testnet",
nativeSymbol: "ETH",
evmChainId: 11155111,
addressType: "hex",
explorerUrl: "https://sepolia.etherscan.io",
decimals: 18,
testnet: true,
};
await registry.registerChain(newChain);
const chain = await registry.getChain("ethereum");
expect(chain?.name).toBe("Sepolia Testnet");
});
});
describe("ChainRegistry", () => {
it("uses MemoryChainRegistry by default", { timeout: 30000 }, async () => {
const registry = new ChainRegistry();
const eth = await registry.getChain("ethereum");
expect(eth?.evmChainId).toBe(1);
});
it("accepts a custom adapter", { timeout: 30000 }, async () => {
const adapter = new MemoryChainRegistry([]);
const registry = new ChainRegistry(adapter);
const all = await registry.getAllChains();
expect(all).toHaveLength(0);
});
it("gets all chains", { timeout: 30000 }, async () => {
const registry = new ChainRegistry();
const all = await registry.getAllChains();
expect(all.length).toBe(15);
});
it("gets chain by EVM ID", { timeout: 30000 }, async () => {
const registry = new ChainRegistry();
const chain = await registry.getChainByEvmId(1);
expect(chain?.id).toBe("ethereum");
});
it("registers a chain", { timeout: 30000 }, async () => {
const registry = new ChainRegistry();
const chain: ChainInfo = {
id: "bitcoin",
name: "Updated BTC",
nativeSymbol: "BTC",
addressType: "bech32",
explorerUrl: "https://btc.io",
decimals: 8,
testnet: false,
};
await registry.registerChain(chain);
const result = await registry.getChain("bitcoin");
expect(result?.name).toBe("Updated BTC");
});
it("gets EVM chains only", { timeout: 30000 }, async () => {
const registry = new ChainRegistry();
const evmChains = await registry.getEvmChains();
expect(evmChains.length).toBeGreaterThan(0);
expect(evmChains.every((c) => c.evmChainId !== undefined)).toBe(true);
expect(evmChains.find((c) => c.id === "bitcoin")).toBeUndefined();
});
it("builds explorer address URL", { timeout: 30000 }, async () => {
const registry = new ChainRegistry();
const url = await registry.getExplorerAddressUrl("ethereum", "0xabc");
expect(url).toBe("https://etherscan.io/address/0xabc");
});
it("builds explorer tx URL", { timeout: 30000 }, async () => {
const registry = new ChainRegistry();
const url = await registry.getExplorerTxUrl("bitcoin", "hash123");
expect(url).toBe("https://blockchair.com/bitcoin/tx/hash123");
});
it("ChainRegistry raises CRYPTOCURRENCY.UNKNOWN_CHAIN on unknown chain", {
timeout: 30000,
}, async () => {
const adapter = new MemoryChainRegistry([]);
const registry = new ChainRegistry(adapter);
let addrErr: unknown;
try {
await registry.getExplorerAddressUrl("ethereum", "0xabc");
} catch (e) {
addrErr = e;
}
expect(addrErr).toBeInstanceOf(CryptocurrencyError);
expect((addrErr as CryptocurrencyError).code).toBe(
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN
);
expect((addrErr as CryptocurrencyError).chainId).toBe("ethereum");
let txErr: unknown;
try {
await registry.getExplorerTxUrl("ethereum", "hash");
} catch (e) {
txErr = e;
}
expect(txErr).toBeInstanceOf(CryptocurrencyError);
expect((txErr as CryptocurrencyError).code).toBe(
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN
);
});
it("ChainRegistry logs chain.registered and chain.lookup.miss events", {
timeout: 30000,
}, async () => {
const { logger, events } = makeRecordingLogger();
const adapter = new MemoryChainRegistry([]);
const registry = new ChainRegistry(adapter, logger);
const chain: ChainInfo = {
id: "polygon",
name: "Polygon",
nativeSymbol: "POL",
evmChainId: 137,
addressType: "hex",
explorerUrl: "https://polygonscan.com",
decimals: 18,
testnet: false,
};
await registry.registerChain(chain);
await expect(
registry.getExplorerAddressUrl("ethereum", "0xabc")
).rejects.toBeInstanceOf(CryptocurrencyError);
const registered = events.find((e) => e.message === "chain.registered");
const miss = events.find((e) => e.message === "chain.lookup.miss");
expect(registered?.level).toBe("info");
expect(registered?.context).toEqual({
chainId: "polygon",
evmChainId: 137,
});
expect(miss?.level).toBe("debug");
expect(miss?.context).toEqual({ chainId: "ethereum" });
});
});
describe("chain-registry source", () => {
it("chain-registry source has no node/bun imports (tier purity)", {
timeout: 5000,
}, () => {
const src = readFileSync(
new URL("./chain-registry.ts", import.meta.url),
"utf8"
);
expect(src).not.toMatch(/from ['"]node:/);
expect(src).not.toMatch(/from ['"]bun:/);
expect(src).not.toMatch(
/from ['"](fs|path|stream|net|http|crypto)['"]/
);
});
});

View File

@@ -0,0 +1,150 @@
import type { LoggerLike } from "@suaveplan/types";
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
} from "../error-codes/error-codes.js";
import type {
ChainId,
ChainInfo,
ChainRegistryAdapter,
} from "../types/crypto.types.js";
import { CHAINS } from "./chain-data.js";
const NOOP = (): void => {};
const NOOP_LOGGER: LoggerLike = {
debug: NOOP,
info: NOOP,
warn: NOOP,
error: NOOP,
};
/**
* In-memory implementation of `ChainRegistryAdapter`. Wraps the built-in
* chain data and allows runtime additions.
*/
export class MemoryChainRegistry implements ChainRegistryAdapter {
private readonly chains: Map<string, ChainInfo>;
public constructor(initialChains?: ChainInfo[]) {
this.chains = new Map();
const source = initialChains ?? Object.values(CHAINS);
for (const chain of source) {
this.chains.set(chain.id, chain);
}
}
public async getChain(id: ChainId): Promise<ChainInfo | undefined> {
return this.chains.get(id);
}
public async getAllChains(): Promise<ChainInfo[]> {
return Array.from(this.chains.values());
}
public async getChainByEvmId(
evmChainId: number
): Promise<ChainInfo | undefined> {
for (const chain of this.chains.values()) {
if (chain.evmChainId === evmChainId) return chain;
}
return undefined;
}
public async registerChain(chain: ChainInfo): Promise<void> {
this.chains.set(chain.id, chain);
}
}
/**
* Async chain registry that delegates to a `ChainRegistryAdapter`. Provides
* the same conceptual API as the synchronous registry but supports
* swappable storage backends (in-memory, database, HTTP) and emits typed
* `CryptocurrencyError` for missing-chain explorer-URL lookups.
*
* The constructor accepts an optional `LoggerLike` for operational events
* (`chain.registered` info, `chain.lookup.miss` debug). The default is a
* no-op logger — wiring is free when no logger is provided.
*/
export class ChainRegistry {
private readonly adapter: ChainRegistryAdapter;
private readonly logger: LoggerLike;
public constructor(adapter?: ChainRegistryAdapter, logger?: LoggerLike) {
this.adapter = adapter ?? new MemoryChainRegistry();
this.logger = logger ?? NOOP_LOGGER;
}
/** Get chain info by ID. */
public async getChain(id: ChainId): Promise<ChainInfo | undefined> {
return this.adapter.getChain(id);
}
/** Get all registered chains. */
public async getAllChains(): Promise<ChainInfo[]> {
return this.adapter.getAllChains();
}
/** Find chain by EVM chain ID. */
public async getChainByEvmId(
evmChainId: number
): Promise<ChainInfo | undefined> {
return this.adapter.getChainByEvmId(evmChainId);
}
/** Register a new chain or update existing. Emits `chain.registered`. */
public async registerChain(chain: ChainInfo): Promise<void> {
await this.adapter.registerChain(chain);
this.logger.info("chain.registered", {
chainId: chain.id,
evmChainId: chain.evmChainId,
});
}
/** Get all EVM-compatible chains. */
public async getEvmChains(): Promise<ChainInfo[]> {
const all = await this.adapter.getAllChains();
return all.filter((c) => c.evmChainId !== undefined);
}
/**
* Build an explorer URL for an address. Raises
* `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })`
* when the adapter has no entry for `chainId`.
*/
public async getExplorerAddressUrl(
chainId: ChainId,
address: string
): Promise<string> {
const chain = await this.adapter.getChain(chainId);
if (!chain) {
this.logger.debug("chain.lookup.miss", { chainId });
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN,
`Unknown chain: ${chainId}`,
{ chainId }
);
}
return `${chain.explorerUrl}/address/${address}`;
}
/**
* Build an explorer URL for a transaction hash. Raises
* `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })`
* when the adapter has no entry for `chainId`.
*/
public async getExplorerTxUrl(
chainId: ChainId,
txHash: string
): Promise<string> {
const chain = await this.adapter.getChain(chainId);
if (!chain) {
this.logger.debug("chain.lookup.miss", { chainId });
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN,
`Unknown chain: ${chainId}`,
{ chainId }
);
}
return `${chain.explorerUrl}/tx/${txHash}`;
}
}

View File

@@ -0,0 +1,10 @@
export { CHAINS } from "./chain-data.js";
export { ChainRegistry, MemoryChainRegistry } from "./chain-registry.js";
export {
getAllChainIds,
getChain,
getChainByEvmId,
getEvmChains,
getExplorerAddressUrl,
getExplorerTxUrl,
} from "./registry.js";

View File

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

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getAllChainIds,
getChain,
getChainByEvmId,
getEvmChains,
getExplorerAddressUrl,
getExplorerTxUrl,
} from "./registry.js";
describe("getChain", () => {
it("returns Ethereum chain info", { timeout: 5000 }, () => {
const eth = getChain("ethereum");
expect(eth.name).toBe("Ethereum");
expect(eth.nativeSymbol).toBe("ETH");
expect(eth.evmChainId).toBe(1);
expect(eth.decimals).toBe(18);
expect(eth.addressType).toBe("hex");
});
it("returns Bitcoin chain info", { timeout: 5000 }, () => {
const btc = getChain("bitcoin");
expect(btc.name).toBe("Bitcoin");
expect(btc.nativeSymbol).toBe("BTC");
expect(btc.evmChainId).toBeUndefined();
expect(btc.decimals).toBe(8);
});
it("returns Solana chain info", { timeout: 5000 }, () => {
const sol = getChain("solana");
expect(sol.nativeSymbol).toBe("SOL");
expect(sol.decimals).toBe(9);
expect(sol.addressType).toBe("base58");
});
it("returns Polygon with correct EVM chain ID", { timeout: 5000 }, () => {
const polygon = getChain("polygon");
expect(polygon.evmChainId).toBe(137);
});
it("returns Base with correct EVM chain ID", { timeout: 5000 }, () => {
const base = getChain("base");
expect(base.evmChainId).toBe(8453);
});
});
describe("getAllChainIds", () => {
it("returns all chain IDs", { timeout: 5000 }, () => {
const ids = getAllChainIds();
expect(ids.length).toBe(15);
expect(ids).toContain("bitcoin");
expect(ids).toContain("ethereum");
expect(ids).toContain("solana");
});
});
describe("getEvmChains", () => {
it("returns only EVM-compatible chains", { timeout: 5000 }, () => {
const evmChains = getEvmChains();
expect(evmChains.length).toBeGreaterThan(0);
expect(evmChains.every((c) => c.evmChainId !== undefined)).toBe(true);
expect(evmChains.find((c) => c.id === "ethereum")).toBeDefined();
expect(evmChains.find((c) => c.id === "bitcoin")).toBeUndefined();
});
});
describe("getChainByEvmId", () => {
it("finds chain by EVM chain ID", { timeout: 5000 }, () => {
const chain = getChainByEvmId(1);
expect(chain?.id).toBe("ethereum");
});
it("finds Polygon by chain ID 137", { timeout: 5000 }, () => {
const chain = getChainByEvmId(137);
expect(chain?.id).toBe("polygon");
});
it("returns undefined for unknown chain ID", { timeout: 5000 }, () => {
expect(getChainByEvmId(99999)).toBeUndefined();
});
});
describe("explorer URLs", () => {
it("builds address URL", { timeout: 5000 }, () => {
const url = getExplorerAddressUrl("ethereum", "0xabc");
expect(url).toBe("https://etherscan.io/address/0xabc");
});
it("builds tx URL", { timeout: 5000 }, () => {
const url = getExplorerTxUrl("bitcoin", "abc123");
expect(url).toBe("https://blockchair.com/bitcoin/tx/abc123");
});
it("builds Solana explorer URL", { timeout: 5000 }, () => {
const url = getExplorerTxUrl("solana", "sig123");
expect(url).toBe("https://solscan.io/tx/sig123");
});
});

View File

@@ -0,0 +1,40 @@
import type { ChainId, ChainInfo } from "../types/crypto.types.js";
import { CHAINS } from "./chain-data.js";
/**
* Get chain info by ID. Returns the `ChainInfo` directly from the built-in
* `CHAINS` constant.
*/
export function getChain(id: ChainId): ChainInfo {
return CHAINS[id];
}
/** Get all 15 built-in chain IDs. */
export function getAllChainIds(): ChainId[] {
return Object.keys(CHAINS) as ChainId[];
}
/** Get all EVM-compatible chains (those with a defined `evmChainId`). */
export function getEvmChains(): ChainInfo[] {
return Object.values(CHAINS).filter((c) => c.evmChainId !== undefined);
}
/** Find a chain by its EVM chain ID, or `undefined` if no built-in matches. */
export function getChainByEvmId(evmChainId: number): ChainInfo | undefined {
return Object.values(CHAINS).find((c) => c.evmChainId === evmChainId);
}
/** Build an explorer URL for an address using the chain's built-in template. */
export function getExplorerAddressUrl(
chainId: ChainId,
address: string
): string {
const chain = CHAINS[chainId];
return `${chain.explorerUrl}/address/${address}`;
}
/** Build an explorer URL for a transaction hash using the chain's template. */
export function getExplorerTxUrl(chainId: ChainId, txHash: string): string {
const chain = CHAINS[chainId];
return `${chain.explorerUrl}/tx/${txHash}`;
}

View File

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

View File

@@ -0,0 +1,92 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTOCURRENCY_CODES, CryptocurrencyError } from "./error-codes.js";
describe("CRYPTOCURRENCY_CODES registry", () => {
it("declares every required code with namespaced value", {
timeout: 5000,
}, () => {
expect(CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN).toBe(
"CRYPTOCURRENCY.UNKNOWN_CHAIN"
);
expect(CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY).toBe(
"CRYPTOCURRENCY.UNKNOWN_CURRENCY"
);
expect(CRYPTOCURRENCY_CODES.UNKNOWN_UNIT).toBe(
"CRYPTOCURRENCY.UNKNOWN_UNIT"
);
expect(CRYPTOCURRENCY_CODES.INVALID_ADDRESS).toBe(
"CRYPTOCURRENCY.INVALID_ADDRESS"
);
expect(CRYPTOCURRENCY_CODES.INVALID_HASH).toBe(
"CRYPTOCURRENCY.INVALID_HASH"
);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(CRYPTOCURRENCY_CODES)).toBe(true);
});
});
describe("CryptocurrencyError", () => {
it("every CRYPTOCURRENCY.* code is reachable from a call site", {
timeout: 5000,
}, () => {
for (const code of Object.values(CRYPTOCURRENCY_CODES)) {
const err = new CryptocurrencyError(code, `test ${code}`);
expect(err.code).toBe(code);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(CryptocurrencyError);
expect(err).toBeInstanceOf(Error);
}
});
it("CryptocurrencyError preserves cause and SuaveplanError contract", {
timeout: 5000,
}, () => {
const root = new Error("underlying boom");
const err = new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN,
"wrap",
{ cause: root }
);
expect(err.cause).toBe(root);
expect(err.code).toBe("CRYPTOCURRENCY.UNKNOWN_CHAIN");
expect(err.timestamp).toBeTypeOf("number");
expect(err.message).toBe("wrap");
});
it("exposes chainId discriminant when provided", { timeout: 5000 }, () => {
const err = new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN,
"missing",
{ chainId: "ethereum" }
);
expect(err.chainId).toBe("ethereum");
expect(err.currency).toBeUndefined();
expect(err.unit).toBeUndefined();
});
it("exposes currency and unit discriminants when provided", {
timeout: 5000,
}, () => {
const err = new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_UNIT,
"bad unit",
{ currency: "ETH", unit: "fake" }
);
expect(err.currency).toBe("ETH");
expect(err.unit).toBe("fake");
expect(err.chainId).toBeUndefined();
});
it("omits discriminants when not provided", { timeout: 5000 }, () => {
const err = new CryptocurrencyError(
CRYPTOCURRENCY_CODES.INVALID_ADDRESS,
"bad address"
);
expect(err.chainId).toBeUndefined();
expect(err.currency).toBeUndefined();
expect(err.unit).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
import type { ChainId } from "../types/crypto.types.js";
/**
* Stable error-code registry for `@suaveplan/cryptocurrency`. Every public
* failure path raises a `CryptocurrencyError` carrying one of these codes;
* consumers programmatically dispatch on the namespaced string
* (`"CRYPTOCURRENCY.UNKNOWN_CHAIN"` etc.).
*
* Code roles:
* - `UNKNOWN_CHAIN` — adapter / registry returned no chain for the id.
* - `UNKNOWN_CURRENCY` — unit converter received a currency outside ETH/BTC/SOL.
* - `UNKNOWN_UNIT` — unit converter received a denomination not in the currency's registry.
* - `INVALID_ADDRESS` — application-tier wrapping of a failed `AddressValidation`.
* - `INVALID_HASH` — application-tier wrapping of a failed `TxHashValidation`.
*/
export const CRYPTOCURRENCY_CODES = defineCodes("CRYPTOCURRENCY", [
"UNKNOWN_CHAIN",
"UNKNOWN_CURRENCY",
"UNKNOWN_UNIT",
"INVALID_ADDRESS",
"INVALID_HASH",
] as const);
export type CryptocurrencyErrorCode =
(typeof CRYPTOCURRENCY_CODES)[keyof typeof CRYPTOCURRENCY_CODES];
export interface CryptocurrencyErrorOptions extends SuaveplanErrorOptions {
/** Chain id that triggered the error, when applicable. */
readonly chainId?: ChainId;
/** Currency tag that triggered the error, when applicable. */
readonly currency?: string;
/** Unit name that triggered the error, when applicable. */
readonly unit?: string;
}
/**
* Discriminated error class for `@suaveplan/cryptocurrency`. Extends
* `SuaveplanError` so `instanceof` narrows `code` to
* `CryptocurrencyErrorCode`; carries optional `chainId` / `currency` /
* `unit` discriminants for diagnostics without polluting the base
* `context` map.
*/
export class CryptocurrencyError extends SuaveplanError<CryptocurrencyErrorCode> {
public readonly chainId?: ChainId;
public readonly currency?: string;
public readonly unit?: string;
public constructor(
code: CryptocurrencyErrorCode,
message: string,
options: CryptocurrencyErrorOptions = {}
) {
super(code, message, options);
if (options.chainId !== undefined) {
this.chainId = options.chainId;
}
if (options.currency !== undefined) {
this.currency = options.currency;
}
if (options.unit !== undefined) {
this.unit = options.unit;
}
}
}

View File

@@ -0,0 +1,8 @@
export type {
CryptocurrencyErrorCode,
CryptocurrencyErrorOptions,
} from "./error-codes.js";
export {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
} from "./error-codes.js";

View File

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

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
calculateEffectiveGasPrice,
calculateMaxFeePerGas,
estimateGas,
estimateTxCostEth,
formatGwei,
} from "./gas-estimator.js";
describe("estimateGas", () => {
it("calculates gas estimate from EIP-1559 params", {
timeout: 5000,
}, () => {
const result = estimateGas({
gasLimit: 21000n,
baseFeePerGas: 30000000000n,
maxPriorityFeePerGas: 2000000000n,
});
expect(result.gasLimit).toBe(21000n);
expect(result.maxFeePerGas).toBe(32000000000n);
expect(result.maxPriorityFeePerGas).toBe(2000000000n);
expect(result.gasPrice).toBe(32000000000n);
expect(result.totalCost).toBe(21000n * 32000000000n);
});
it("uses legacy gas price when provided", { timeout: 5000 }, () => {
const result = estimateGas({
gasLimit: 21000n,
baseFeePerGas: 30000000000n,
maxPriorityFeePerGas: 2000000000n,
legacyGasPrice: 25000000000n,
});
expect(result.gasPrice).toBe(25000000000n);
expect(result.totalCost).toBe(21000n * 25000000000n);
});
it("handles zero priority fee", { timeout: 5000 }, () => {
const result = estimateGas({
gasLimit: 21000n,
baseFeePerGas: 10000000000n,
maxPriorityFeePerGas: 0n,
});
expect(result.maxFeePerGas).toBe(10000000000n);
expect(result.totalCost).toBe(21000n * 10000000000n);
});
it("handles high gas limit for contract calls", { timeout: 5000 }, () => {
const result = estimateGas({
gasLimit: 200000n,
baseFeePerGas: 50000000000n,
maxPriorityFeePerGas: 3000000000n,
});
expect(result.gasLimit).toBe(200000n);
expect(result.totalCost).toBe(200000n * 53000000000n);
});
});
describe("calculateMaxFeePerGas", () => {
it("computes 2 * baseFee + priority", { timeout: 5000 }, () => {
const result = calculateMaxFeePerGas(30000000000n, 2000000000n);
expect(result).toBe(62000000000n);
});
it("handles zero base fee", { timeout: 5000 }, () => {
expect(calculateMaxFeePerGas(0n, 2000000000n)).toBe(2000000000n);
});
it("handles zero priority fee", { timeout: 5000 }, () => {
expect(calculateMaxFeePerGas(30000000000n, 0n)).toBe(60000000000n);
});
});
describe("calculateEffectiveGasPrice", () => {
it("returns baseFee + priority when below maxFee", {
timeout: 5000,
}, () => {
const result = calculateEffectiveGasPrice(
30000000000n,
100000000000n,
2000000000n
);
expect(result).toBe(32000000000n);
});
it("caps at maxFeePerGas", { timeout: 5000 }, () => {
const result = calculateEffectiveGasPrice(
30000000000n,
31000000000n,
2000000000n
);
expect(result).toBe(31000000000n);
});
it("returns maxFee when baseFee + priority equals maxFee", {
timeout: 5000,
}, () => {
const result = calculateEffectiveGasPrice(
30000000000n,
32000000000n,
2000000000n
);
expect(result).toBe(32000000000n);
});
});
describe("formatGwei", () => {
it("formats whole gwei", { timeout: 5000 }, () => {
expect(formatGwei(30000000000n)).toBe("30");
});
it("formats fractional gwei", { timeout: 5000 }, () => {
expect(formatGwei(2500000000n)).toBe("2.5");
});
it("formats zero", { timeout: 5000 }, () => {
expect(formatGwei(0n)).toBe("0");
});
it("formats 1 wei", { timeout: 5000 }, () => {
expect(formatGwei(1n)).toBe("0.000000001");
});
it("formats large values", { timeout: 5000 }, () => {
expect(formatGwei(100000000000n)).toBe("100");
});
it("trims trailing zeros", { timeout: 5000 }, () => {
expect(formatGwei(1100000000n)).toBe("1.1");
});
it("handles negative values", { timeout: 5000 }, () => {
expect(formatGwei(-2500000000n)).toBe("-2.5");
});
it("handles negative whole gwei", { timeout: 5000 }, () => {
expect(formatGwei(-30000000000n)).toBe("-30");
});
});
describe("estimateTxCostEth", () => {
it("estimates simple transfer cost", { timeout: 5000 }, () => {
const result = estimateTxCostEth(21000n, 30000000000n);
expect(result).toBe("0.00063");
});
it("formats zero cost", { timeout: 5000 }, () => {
expect(estimateTxCostEth(0n, 30000000000n)).toBe("0");
});
it("estimates contract interaction cost", { timeout: 5000 }, () => {
const result = estimateTxCostEth(200000n, 50000000000n);
expect(result).toBe("0.01");
});
it("handles very small costs", { timeout: 5000 }, () => {
const result = estimateTxCostEth(21000n, 1000000000n);
expect(result).toBe("0.000021");
});
it("handles costs landing on exact whole ETH", { timeout: 5000 }, () => {
// 1 ETH worth of gas: gasLimit * gasPrice = 10^18
const result = estimateTxCostEth(1000000000n, 1000000000n);
expect(result).toBe("1");
});
});

View File

@@ -0,0 +1,107 @@
import type { GasEstimate, GasEstimateParams } from "../types/crypto.types.js";
const GWEI = 1000000000n;
const ETH_DECIMALS = 18;
const ETH_FACTOR = 10n ** BigInt(ETH_DECIMALS);
/**
* Estimate total gas cost using EIP-1559 fee parameters.
*
* `maxFeePerGas` is computed as `baseFeePerGas + maxPriorityFeePerGas`.
* If `legacyGasPrice` is provided, it overrides the effective gas price
* (for legacy non-EIP-1559 transactions). `totalCost` is `gasLimit *
* effectiveGasPrice`.
*
* @example
* const estimate = estimateGas({
* gasLimit: 21000n,
* baseFeePerGas: 30000000000n, // 30 gwei
* maxPriorityFeePerGas: 2000000000n, // 2 gwei
* });
*/
export function estimateGas(params: GasEstimateParams): GasEstimate {
const { gasLimit, baseFeePerGas, maxPriorityFeePerGas, legacyGasPrice } =
params;
const maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas;
const effectiveGasPrice = legacyGasPrice ?? maxFeePerGas;
const totalCost = gasLimit * effectiveGasPrice;
return {
gasLimit,
gasPrice: effectiveGasPrice,
maxFeePerGas,
maxPriorityFeePerGas,
totalCost,
};
}
/**
* Calculate the max fee per gas using the EIP-1559 ceiling formula:
* `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. This accounts
* for up to one block of base fee increase.
*/
export function calculateMaxFeePerGas(
baseFeePerGas: bigint,
maxPriorityFeePerGas: bigint
): bigint {
return 2n * baseFeePerGas + maxPriorityFeePerGas;
}
/**
* Calculate the effective gas price from EIP-1559 parameters:
* `effectiveGasPrice = min(maxFeePerGas, baseFeePerGas + maxPriorityFeePerGas)`.
*/
export function calculateEffectiveGasPrice(
baseFeePerGas: bigint,
maxFeePerGas: bigint,
maxPriorityFeePerGas: bigint
): bigint {
const target = baseFeePerGas + maxPriorityFeePerGas;
return target < maxFeePerGas ? target : maxFeePerGas;
}
/**
* Format a wei value as a human-readable gwei string. Trailing zeros are
* trimmed; the sign is preserved.
*
* @example
* formatGwei(30000000000n) // '30'
* formatGwei(2500000000n) // '2.5'
* formatGwei(-2500000000n) // '-2.5'
*/
export function formatGwei(weiValue: bigint): string {
const negative = weiValue < 0n;
const abs = negative ? -weiValue : weiValue;
const whole = abs / GWEI;
const frac = abs % GWEI;
const sign = negative ? "-" : "";
if (frac === 0n) return `${sign}${whole}`;
const fracStr = frac.toString().padStart(9, "0");
const trimmed = fracStr.replace(/0+$/, "");
return `${sign}${whole}.${trimmed}`;
}
/**
* Estimate total transaction cost in ETH as a decimal string. Trailing
* zeros are trimmed.
*
* @example
* estimateTxCostEth(21000n, 30000000000n) // '0.00063'
*/
export function estimateTxCostEth(
gasLimit: bigint,
gasPriceWei: bigint
): string {
const totalWei = gasLimit * gasPriceWei;
const whole = totalWei / ETH_FACTOR;
const frac = totalWei % ETH_FACTOR;
if (frac === 0n) return whole.toString();
const fracStr = frac.toString().padStart(ETH_DECIMALS, "0");
const trimmed = fracStr.replace(/0+$/, "");
return `${whole}.${trimmed}`;
}

View File

@@ -0,0 +1,7 @@
export {
calculateEffectiveGasPrice,
calculateMaxFeePerGas,
estimateGas,
estimateTxCostEth,
formatGwei,
} from "./gas-estimator.js";

View File

@@ -0,0 +1,80 @@
// Address validation
export {
validateAddress,
validateAddressForChain,
validateTxHash,
} from "./address/validator.js";
// Chain data
export { CHAINS } from "./chains/chain-data.js";
// Chain registry (async façade)
export { ChainRegistry, MemoryChainRegistry } from "./chains/chain-registry.js";
// Chain getters (sync)
export {
getAllChainIds,
getChain,
getChainByEvmId,
getEvmChains,
getExplorerAddressUrl,
getExplorerTxUrl,
} from "./chains/registry.js";
// Errors
export {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
type CryptocurrencyErrorCode,
type CryptocurrencyErrorOptions,
} from "./error-codes/error-codes.js";
// Gas
export {
calculateEffectiveGasPrice,
calculateMaxFeePerGas,
estimateGas,
estimateTxCostEth,
formatGwei,
} from "./gas/gas-estimator.js";
// Tokens
export { COMMON_TOKENS } from "./tokens/token-data.js";
export { formatTokenBalance, TokenRegistry } from "./tokens/token-registry.js";
// Transaction
export {
decodeCalldata,
decodeTxHash,
extractSelector,
KNOWN_SELECTORS,
lookupSelector,
} from "./transaction/tx-decoder.js";
// Types
export type {
AddressType,
AddressValidation,
ChainId,
ChainInfo,
ChainRegistryAdapter,
CryptoUnit,
FunctionSignature,
GasEstimate,
GasEstimateParams,
TokenInfo,
TxHashValidation,
TxMetadata,
} from "./types/crypto.types.js";
// Units
export {
bitcoinToSatoshi,
convertUnits,
etherToWei,
getUnits,
gweiToEther,
lamportToSol,
satoshiToBitcoin,
weiToEther,
} from "./units/converter.js";

View File

@@ -0,0 +1,2 @@
export { COMMON_TOKENS } from "./token-data.js";
export { formatTokenBalance, TokenRegistry } from "./token-registry.js";

View File

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

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { COMMON_TOKENS } from "./token-data.js";
describe("COMMON_TOKENS", () => {
it("COMMON_TOKENS contains all 8 well-known ERC-20s", {
timeout: 5000,
}, () => {
const symbols = COMMON_TOKENS.map((t) => t.symbol).sort();
expect(symbols).toEqual(
[
"DAI",
"LINK",
"MATIC",
"UNI",
"USDC",
"USDT",
"WBTC",
"WETH",
].sort()
);
});
it("all entries are ethereum chainId", { timeout: 5000 }, () => {
for (const token of COMMON_TOKENS) {
expect(token.chainId).toBe("ethereum");
}
});
it("all entries have required fields populated", { timeout: 5000 }, () => {
for (const token of COMMON_TOKENS) {
expect(token.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
expect(token.symbol).toBeTruthy();
expect(token.name).toBeTruthy();
expect(token.decimals).toBeGreaterThanOrEqual(0);
}
});
it("stablecoins use 6 decimals", { timeout: 5000 }, () => {
const usdt = COMMON_TOKENS.find((t) => t.symbol === "USDT");
const usdc = COMMON_TOKENS.find((t) => t.symbol === "USDC");
expect(usdt?.decimals).toBe(6);
expect(usdc?.decimals).toBe(6);
});
it("WBTC uses 8 decimals", { timeout: 5000 }, () => {
const wbtc = COMMON_TOKENS.find((t) => t.symbol === "WBTC");
expect(wbtc?.decimals).toBe(8);
});
});

View File

@@ -0,0 +1,64 @@
import type { TokenInfo } from "../types/crypto.types.js";
/**
* Well-known ERC-20 tokens on Ethereum mainnet. Preloaded by
* `TokenRegistry` when constructed with `preload = true` (default).
*/
export const COMMON_TOKENS: TokenInfo[] = [
{
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
symbol: "USDT",
decimals: 6,
chainId: "ethereum",
name: "Tether USD",
},
{
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
symbol: "USDC",
decimals: 6,
chainId: "ethereum",
name: "USD Coin",
},
{
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
chainId: "ethereum",
name: "Dai Stablecoin",
},
{
address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
symbol: "WBTC",
decimals: 8,
chainId: "ethereum",
name: "Wrapped BTC",
},
{
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
symbol: "WETH",
decimals: 18,
chainId: "ethereum",
name: "Wrapped Ether",
},
{
address: "0x514910771AF9Ca656af840dff83E8264EcF986CA",
symbol: "LINK",
decimals: 18,
chainId: "ethereum",
name: "ChainLink Token",
},
{
address: "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984",
symbol: "UNI",
decimals: 18,
chainId: "ethereum",
name: "Uniswap",
},
{
address: "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0",
symbol: "MATIC",
decimals: 18,
chainId: "ethereum",
name: "Polygon (Matic)",
},
];

View File

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

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { COMMON_TOKENS } from "./token-data.js";
import { formatTokenBalance, TokenRegistry } from "./token-registry.js";
describe("TokenRegistry", () => {
it("preloads common tokens by default", { timeout: 5000 }, () => {
const registry = new TokenRegistry();
expect(registry.size).toBe(COMMON_TOKENS.length);
});
it("starts empty when preload is false", { timeout: 5000 }, () => {
const registry = new TokenRegistry(false);
expect(registry.size).toBe(0);
});
it("adds and retrieves a token", { timeout: 5000 }, () => {
const registry = new TokenRegistry(false);
registry.addToken({
address: "0xABC",
symbol: "TEST",
decimals: 18,
chainId: "ethereum",
name: "Test Token",
});
const token = registry.getToken("ethereum", "0xABC");
expect(token).toBeDefined();
expect(token?.symbol).toBe("TEST");
});
it("retrieves token with case-insensitive address", {
timeout: 5000,
}, () => {
const registry = new TokenRegistry(false);
registry.addToken({
address: "0xAbC",
symbol: "TEST",
decimals: 18,
chainId: "ethereum",
name: "Test Token",
});
const token = registry.getToken("ethereum", "0xabc");
expect(token).toBeDefined();
});
it("returns undefined for unknown token", { timeout: 5000 }, () => {
const registry = new TokenRegistry(false);
expect(registry.getToken("ethereum", "0xunknown")).toBeUndefined();
});
it("gets tokens by chain", { timeout: 5000 }, () => {
const registry = new TokenRegistry();
const ethTokens = registry.getTokensByChain("ethereum");
expect(ethTokens.length).toBeGreaterThan(0);
expect(ethTokens.every((t) => t.chainId === "ethereum")).toBe(true);
});
it("returns empty array for chain with no tokens", {
timeout: 5000,
}, () => {
const registry = new TokenRegistry();
const btcTokens = registry.getTokensByChain("bitcoin");
expect(btcTokens).toHaveLength(0);
});
it("searches by symbol", { timeout: 5000 }, () => {
const registry = new TokenRegistry();
const results = registry.searchBySymbol("USD");
expect(results.length).toBeGreaterThanOrEqual(2);
expect(results.some((t) => t.symbol === "USDT")).toBe(true);
expect(results.some((t) => t.symbol === "USDC")).toBe(true);
});
it("searches by symbol case-insensitively", { timeout: 5000 }, () => {
const registry = new TokenRegistry();
const results = registry.searchBySymbol("usd");
expect(results.length).toBeGreaterThanOrEqual(2);
});
it("searches by name", { timeout: 5000 }, () => {
const registry = new TokenRegistry();
const results = registry.searchByName("Wrapped");
expect(results.length).toBeGreaterThanOrEqual(2);
expect(results.some((t) => t.symbol === "WBTC")).toBe(true);
expect(results.some((t) => t.symbol === "WETH")).toBe(true);
});
it("searches by name case-insensitively", { timeout: 5000 }, () => {
const registry = new TokenRegistry();
const results = registry.searchByName("wrapped");
expect(results.length).toBeGreaterThanOrEqual(2);
});
it("returns empty for no search match", { timeout: 5000 }, () => {
const registry = new TokenRegistry();
expect(registry.searchBySymbol("ZZZZZ")).toHaveLength(0);
expect(registry.searchByName("nonexistent")).toHaveLength(0);
});
it("gets all tokens", { timeout: 5000 }, () => {
const registry = new TokenRegistry();
const all = registry.getAllTokens();
expect(all.length).toBe(COMMON_TOKENS.length);
});
it("addToken upserts by chain+address", { timeout: 5000 }, () => {
const registry = new TokenRegistry(false);
registry.addToken({
address: "0xAbC",
symbol: "OLD",
decimals: 6,
chainId: "ethereum",
name: "Old",
});
registry.addToken({
address: "0xabc",
symbol: "NEW",
decimals: 18,
chainId: "ethereum",
name: "New",
});
expect(registry.size).toBe(1);
expect(registry.getToken("ethereum", "0xABC")?.symbol).toBe("NEW");
});
});
describe("formatTokenBalance", () => {
it("formats whole number balance", { timeout: 5000 }, () => {
expect(formatTokenBalance(1000000n, 6)).toBe("1");
});
it("formats fractional balance", { timeout: 5000 }, () => {
expect(formatTokenBalance(1500000n, 6)).toBe("1.5");
});
it("formats zero", { timeout: 5000 }, () => {
expect(formatTokenBalance(0n, 18)).toBe("0");
});
it("formats with no decimals", { timeout: 5000 }, () => {
expect(formatTokenBalance(42n, 0)).toBe("42");
});
it("formats small amounts", { timeout: 5000 }, () => {
expect(formatTokenBalance(1n, 6)).toBe("0.000001");
});
it("formats 18-decimal token", { timeout: 5000 }, () => {
expect(formatTokenBalance(1000000000000000000n, 18)).toBe("1");
});
it("trims trailing zeros", { timeout: 5000 }, () => {
expect(formatTokenBalance(1100000n, 6)).toBe("1.1");
});
it("handles negative balances", { timeout: 5000 }, () => {
expect(formatTokenBalance(-1500000n, 6)).toBe("-1.5");
});
it("handles negative whole balances", { timeout: 5000 }, () => {
expect(formatTokenBalance(-1000000n, 6)).toBe("-1");
});
it("handles large values", { timeout: 5000 }, () => {
expect(formatTokenBalance(1000000000000000000000n, 18)).toBe("1000");
});
});

View File

@@ -0,0 +1,101 @@
import type { ChainId, TokenInfo } from "../types/crypto.types.js";
import { COMMON_TOKENS } from "./token-data.js";
/**
* Registry for ERC-20 / token information. Supports adding, retrieving,
* and searching tokens. Address keys are normalised to lowercase so
* lookups are case-insensitive.
*/
export class TokenRegistry {
private readonly tokens: Map<string, TokenInfo> = new Map();
public constructor(preload = true) {
if (preload) {
for (const token of COMMON_TOKENS) {
this.addToken(token);
}
}
}
/** Generate a unique key for a token (chain + address lowercase). */
private key(chainId: ChainId, address: string): string {
return `${chainId}:${address.toLowerCase()}`;
}
/** Add a token to the registry (upsert by chain + address). */
public addToken(token: TokenInfo): void {
this.tokens.set(this.key(token.chainId, token.address), token);
}
/** Get a token by chain and address (case-insensitive on address). */
public getToken(chainId: ChainId, address: string): TokenInfo | undefined {
return this.tokens.get(this.key(chainId, address));
}
/** Get all tokens for a specific chain. */
public getTokensByChain(chainId: ChainId): TokenInfo[] {
const result: TokenInfo[] = [];
for (const token of this.tokens.values()) {
if (token.chainId === chainId) result.push(token);
}
return result;
}
/** Search tokens by symbol (case-insensitive substring match). */
public searchBySymbol(query: string): TokenInfo[] {
const q = query.toUpperCase();
const result: TokenInfo[] = [];
for (const token of this.tokens.values()) {
if (token.symbol.toUpperCase().includes(q)) result.push(token);
}
return result;
}
/** Search tokens by name (case-insensitive substring match). */
public searchByName(query: string): TokenInfo[] {
const q = query.toLowerCase();
const result: TokenInfo[] = [];
for (const token of this.tokens.values()) {
if (token.name.toLowerCase().includes(q)) result.push(token);
}
return result;
}
/** Get the total number of registered tokens. */
public get size(): number {
return this.tokens.size;
}
/** Get all registered tokens. */
public getAllTokens(): TokenInfo[] {
return Array.from(this.tokens.values());
}
}
/**
* Format a raw token balance (bigint) into a human-readable decimal string.
* Trailing zeros are trimmed; the sign is preserved.
*
* @example
* formatTokenBalance(1000000n, 6) // '1'
* formatTokenBalance(1500000n, 6) // '1.5'
* formatTokenBalance(-1500000n, 6) // '-1.5'
*/
export function formatTokenBalance(
rawBalance: bigint,
decimals: number
): string {
if (decimals === 0) return rawBalance.toString();
const negative = rawBalance < 0n;
const abs = negative ? -rawBalance : rawBalance;
const factor = 10n ** BigInt(decimals);
const whole = abs / factor;
const frac = abs % factor;
const fracStr = frac.toString().padStart(decimals, "0");
const trimmed = fracStr.replace(/0+$/, "") || "0";
const sign = negative ? "-" : "";
if (trimmed === "0") return `${sign}${whole}`;
return `${sign}${whole}.${trimmed}`;
}

View File

@@ -0,0 +1,7 @@
export {
decodeCalldata,
decodeTxHash,
extractSelector,
KNOWN_SELECTORS,
lookupSelector,
} from "./tx-decoder.js";

View File

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

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
decodeCalldata,
decodeTxHash,
extractSelector,
KNOWN_SELECTORS,
lookupSelector,
} from "./tx-decoder.js";
describe("decodeTxHash", () => {
it("decodes EVM transaction hash", { timeout: 5000 }, () => {
const hash = `0x${"a".repeat(64)}`;
const result = decodeTxHash(hash);
expect(result.valid).toBe(true);
expect(result.hashType).toBe("evm");
expect(result.chains).toContain("ethereum");
expect(result.chains).toContain("polygon");
expect(result.hashHex).toBe("a".repeat(64));
});
it("decodes UTXO transaction hash", { timeout: 5000 }, () => {
const hash = "b".repeat(64);
const result = decodeTxHash(hash);
expect(result.valid).toBe(true);
expect(result.hashType).toBe("utxo");
expect(result.chains).toContain("bitcoin");
expect(result.chains).toContain("litecoin");
expect(result.chains).toContain("dogecoin");
expect(result.hashHex).toBe("b".repeat(64));
});
it("decodes Solana transaction signature", { timeout: 5000 }, () => {
const sig = "5VERv8NMhJnPVv7m2LaJmpGqKqTFTJbLFzDG".padEnd(88, "A");
const result = decodeTxHash(sig);
expect(result.valid).toBe(true);
expect(result.hashType).toBe("solana");
expect(result.chains).toContain("solana");
expect(result.hashHex).toBeUndefined();
});
it("returns invalid for empty input", { timeout: 5000 }, () => {
const result = decodeTxHash("");
expect(result.valid).toBe(false);
expect(result.hashType).toBe("unknown");
});
it("returns invalid for null input", { timeout: 5000 }, () => {
const result = decodeTxHash(null as unknown as string);
expect(result.valid).toBe(false);
});
it("returns invalid for random string", { timeout: 5000 }, () => {
const result = decodeTxHash("not-a-hash");
expect(result.valid).toBe(false);
expect(result.hashType).toBe("unknown");
});
it("trims whitespace", { timeout: 5000 }, () => {
const hash = ` 0x${"c".repeat(64)} `;
const result = decodeTxHash(hash);
expect(result.valid).toBe(true);
expect(result.hashType).toBe("evm");
});
it("distinguishes EVM (0x prefix) from UTXO", { timeout: 5000 }, () => {
const evmHash = `0x${"ab".repeat(32)}`;
const utxoHash = "ab".repeat(32);
expect(decodeTxHash(evmHash).hashType).toBe("evm");
expect(decodeTxHash(utxoHash).hashType).toBe("utxo");
});
});
describe("lookupSelector", () => {
it("finds transfer selector", { timeout: 5000 }, () => {
const result = lookupSelector("0xa9059cbb");
expect(result).toBeDefined();
expect(result?.name).toBe("transfer");
expect(result?.signature).toBe("transfer(address,uint256)");
});
it("finds approve selector", { timeout: 5000 }, () => {
const result = lookupSelector("0x095ea7b3");
expect(result?.name).toBe("approve");
});
it("finds transferFrom selector", { timeout: 5000 }, () => {
const result = lookupSelector("0x23b872dd");
expect(result?.name).toBe("transferFrom");
});
it("is case-insensitive", { timeout: 5000 }, () => {
const result = lookupSelector("0xA9059CBB");
expect(result?.name).toBe("transfer");
});
it("returns undefined for unknown selector", { timeout: 5000 }, () => {
expect(lookupSelector("0x00000000")).toBeUndefined();
});
});
describe("extractSelector", () => {
it("extracts selector from calldata", { timeout: 5000 }, () => {
const calldata = `0xa9059cbb${"0".repeat(128)}`;
expect(extractSelector(calldata)).toBe("0xa9059cbb");
});
it("returns undefined for empty input", { timeout: 5000 }, () => {
expect(extractSelector("")).toBeUndefined();
});
it("returns undefined for null input", { timeout: 5000 }, () => {
expect(extractSelector(null as unknown as string)).toBeUndefined();
});
it("returns undefined for too short calldata", { timeout: 5000 }, () => {
expect(extractSelector("0xabcd")).toBeUndefined();
});
it("returns undefined for non-hex calldata", { timeout: 5000 }, () => {
expect(extractSelector("not-hex-data")).toBeUndefined();
});
it("normalizes to lowercase", { timeout: 5000 }, () => {
const calldata = `0xA9059CBB${"0".repeat(128)}`;
expect(extractSelector(calldata)).toBe("0xa9059cbb");
});
it("trims whitespace", { timeout: 5000 }, () => {
const calldata = ` 0xa9059cbb${"0".repeat(128)} `;
expect(extractSelector(calldata)).toBe("0xa9059cbb");
});
});
describe("decodeCalldata", () => {
it("decodes transfer calldata", { timeout: 5000 }, () => {
const calldata = `0xa9059cbb${"0".repeat(128)}`;
const result = decodeCalldata(calldata);
expect(result).toBeDefined();
expect(result?.name).toBe("transfer");
});
it("decodes approve calldata", { timeout: 5000 }, () => {
const calldata = `0x095ea7b3${"0".repeat(128)}`;
const result = decodeCalldata(calldata);
expect(result?.name).toBe("approve");
});
it("returns undefined for unknown function", { timeout: 5000 }, () => {
const calldata = `0xdeadbeef${"0".repeat(128)}`;
expect(decodeCalldata(calldata)).toBeUndefined();
});
it("returns undefined for invalid calldata", { timeout: 5000 }, () => {
expect(decodeCalldata("not-calldata")).toBeUndefined();
});
it("returns undefined for empty string", { timeout: 5000 }, () => {
expect(decodeCalldata("")).toBeUndefined();
});
});
describe("KNOWN_SELECTORS", () => {
it("contains essential ERC-20 selectors", { timeout: 5000 }, () => {
const names = KNOWN_SELECTORS.map((s) => s.name);
expect(names).toContain("transfer");
expect(names).toContain("approve");
expect(names).toContain("transferFrom");
expect(names).toContain("balanceOf");
expect(names).toContain("allowance");
expect(names).toContain("totalSupply");
});
it("all selectors have valid format", { timeout: 5000 }, () => {
for (const sig of KNOWN_SELECTORS) {
expect(sig.selector).toMatch(/^0x[0-9a-f]{8}$/);
expect(sig.name).toBeTruthy();
expect(sig.signature).toBeTruthy();
}
});
});

View File

@@ -0,0 +1,150 @@
import type {
ChainId,
FunctionSignature,
TxMetadata,
} from "../types/crypto.types.js";
/** Well-known ERC-20 / ERC-721 function selectors. */
export const KNOWN_SELECTORS: FunctionSignature[] = [
{
selector: "0xa9059cbb",
name: "transfer",
signature: "transfer(address,uint256)",
},
{
selector: "0x095ea7b3",
name: "approve",
signature: "approve(address,uint256)",
},
{
selector: "0x23b872dd",
name: "transferFrom",
signature: "transferFrom(address,address,uint256)",
},
{
selector: "0x70a08231",
name: "balanceOf",
signature: "balanceOf(address)",
},
{
selector: "0xdd62ed3e",
name: "allowance",
signature: "allowance(address,address)",
},
{
selector: "0x18160ddd",
name: "totalSupply",
signature: "totalSupply()",
},
{
selector: "0x42842e0e",
name: "safeTransferFrom",
signature: "safeTransferFrom(address,address,uint256)",
},
{
selector: "0xa22cb465",
name: "setApprovalForAll",
signature: "setApprovalForAll(address,bool)",
},
];
const EVM_TX_HASH = /^0x[0-9a-fA-F]{64}$/;
const UTXO_TX_HASH = /^[0-9a-fA-F]{64}$/;
const SOLANA_TX_SIG = /^[1-9A-HJ-NP-Za-km-z]{87,88}$/;
const CALLDATA_PATTERN = /^0x[0-9a-fA-F]{8,}$/;
const EVM_CHAINS: ChainId[] = [
"ethereum",
"polygon",
"arbitrum",
"optimism",
"avalanche",
"bsc",
"base",
];
/**
* Decode a transaction hash and extract metadata. Determines the hash type
* (`evm` / `utxo` / `solana` / `unknown`) and which chains it could belong to.
*/
export function decodeTxHash(hash: string): TxMetadata {
if (!hash || typeof hash !== "string") {
return { hash: "", chains: [], valid: false, hashType: "unknown" };
}
const trimmed = hash.trim();
if (EVM_TX_HASH.test(trimmed)) {
return {
hash: trimmed,
chains: [...EVM_CHAINS],
valid: true,
hashType: "evm",
hashHex: trimmed.slice(2).toLowerCase(),
};
}
if (UTXO_TX_HASH.test(trimmed)) {
return {
hash: trimmed,
chains: ["bitcoin", "litecoin", "dogecoin"],
valid: true,
hashType: "utxo",
hashHex: trimmed.toLowerCase(),
};
}
if (SOLANA_TX_SIG.test(trimmed)) {
return {
hash: trimmed,
chains: ["solana"],
valid: true,
hashType: "solana",
};
}
return { hash: trimmed, chains: [], valid: false, hashType: "unknown" };
}
/**
* Look up a function selector in the known-signatures table. The lookup is
* case-insensitive on the input selector.
*
* @example
* lookupSelector('0xa9059cbb')
* // { selector: '0xa9059cbb', name: 'transfer', signature: 'transfer(address,uint256)' }
*/
export function lookupSelector(
selector: string
): FunctionSignature | undefined {
const normalized = selector.toLowerCase();
return KNOWN_SELECTORS.find((s) => s.selector === normalized);
}
/**
* Extract the 4-byte function selector from calldata. Returns `undefined`
* for empty / non-string / non-hex / too-short input.
*
* @example
* extractSelector('0xa9059cbb000000000000000000000000...')
* // '0xa9059cbb'
*/
export function extractSelector(calldata: string): string | undefined {
if (!calldata || typeof calldata !== "string") return undefined;
const trimmed = calldata.trim();
if (!CALLDATA_PATTERN.test(trimmed)) return undefined;
return trimmed.slice(0, 10).toLowerCase();
}
/**
* Decode calldata and return the matching function signature if known.
* This is a naive matcher — it does not decode the argument bytes, only
* the 4-byte selector. ABI-aware decoding lands in a follow-up change.
*/
export function decodeCalldata(
calldata: string
): FunctionSignature | undefined {
const selector = extractSelector(calldata);
if (!selector) return undefined;
return lookupSelector(selector);
}

View File

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

View File

@@ -0,0 +1,157 @@
/**
* Public type contract for `@suaveplan/cryptocurrency`. Every cross-cutting
* type the package exposes lives here so consumers can import from
* `@suaveplan/cryptocurrency/types/crypto.types` without dragging in any
* runtime code.
*/
/** Supported blockchain networks. */
export type ChainId =
| "bitcoin"
| "ethereum"
| "solana"
| "polygon"
| "arbitrum"
| "optimism"
| "avalanche"
| "bsc"
| "base"
| "tron"
| "litecoin"
| "dogecoin"
| "cardano"
| "polkadot"
| "cosmos";
/** Address-format discriminant for a chain. */
export type AddressType = "base58" | "bech32" | "hex" | "bech32m" | "ss58";
/** Blockchain network metadata. */
export interface ChainInfo {
/** Unique chain identifier. */
id: ChainId;
/** Display name. */
name: string;
/** Native currency symbol. */
nativeSymbol: string;
/** EVM chain ID (undefined for non-EVM chains). */
evmChainId?: number;
/** Address format type. */
addressType: AddressType;
/** Block explorer base URL (path segments are appended by the URL helpers). */
explorerUrl: string;
/** Native currency decimals. */
decimals: number;
/** Whether this is a testnet. Always `false` for the built-in mainnet set. */
testnet: boolean;
}
/** Unit definition for a cryptocurrency. */
export interface CryptoUnit {
/** Unit name (e.g., 'wei', 'gwei', 'ether'). */
name: string;
/** Number of decimals (e.g., 0 for wei, 9 for gwei, 18 for ether). */
decimals: number;
/** Symbol or abbreviation. */
symbol: string;
}
/** Address validation result. */
export interface AddressValidation {
/** Whether the address is valid. */
valid: boolean;
/** The normalized address (e.g., positional-EIP-55-checksummed for EVM). */
normalized?: string;
/** The detected chain(s) this address could belong to. */
chains: ChainId[];
/** Error message if invalid. */
error?: string;
}
/** Transaction hash validation result. */
export interface TxHashValidation {
/** Whether the hash is valid. */
valid: boolean;
/** The detected chain(s). */
chains: ChainId[];
/** Error message if invalid. */
error?: string;
}
/** ERC-20 / token information. */
export interface TokenInfo {
/** Token contract address. */
address: string;
/** Token symbol (e.g., 'USDT'). */
symbol: string;
/** Number of decimals. */
decimals: number;
/** Chain this token lives on. */
chainId: ChainId;
/** Human-readable name. */
name: string;
/** Optional logo URL. */
logoUrl?: string;
}
/** Gas estimation result. */
export interface GasEstimate {
/** Gas limit (units of gas). */
gasLimit: bigint;
/** Effective gas price in wei (legacy override or EIP-1559 maxFeePerGas). */
gasPrice: bigint;
/** EIP-1559 max fee per gas in wei. */
maxFeePerGas: bigint;
/** EIP-1559 max priority fee per gas in wei. */
maxPriorityFeePerGas: bigint;
/** Total estimated cost in wei (`gasLimit * gasPrice`). */
totalCost: bigint;
}
/** Parameters for gas estimation. */
export interface GasEstimateParams {
/** Gas limit (units of gas). */
gasLimit: bigint;
/** Base fee per gas in wei. */
baseFeePerGas: bigint;
/** Max priority fee per gas in wei (tip). */
maxPriorityFeePerGas: bigint;
/** Optional legacy gas price override in wei. */
legacyGasPrice?: bigint;
}
/** Decoded transaction metadata. */
export interface TxMetadata {
/** Raw transaction hash. */
hash: string;
/** Detected chain(s). */
chains: ChainId[];
/** Whether hash format is valid. */
valid: boolean;
/** Hash type. */
hashType: "evm" | "utxo" | "solana" | "unknown";
/** Hash bytes (hex without prefix), populated for evm / utxo paths. */
hashHex?: string;
}
/** Function-signature record for the calldata decoder. */
export interface FunctionSignature {
/** 4-byte selector (e.g., '0xa9059cbb'). */
selector: string;
/** Function name. */
name: string;
/** Human-readable signature. */
signature: string;
}
/** Async adapter interface for chain registry storage. */
export interface ChainRegistryAdapter {
/** Get a chain by its ID. */
getChain(id: ChainId): Promise<ChainInfo | undefined>;
/** Get all registered chains. */
getAllChains(): Promise<ChainInfo[]>;
/** Find a chain by its EVM chain ID. */
getChainByEvmId(evmChainId: number): Promise<ChainInfo | undefined>;
/** Register a new chain or update an existing one. */
registerChain(chain: ChainInfo): Promise<void>;
}

View File

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

View File

@@ -0,0 +1,257 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
} from "../error-codes/error-codes.js";
import {
bitcoinToSatoshi,
convertUnits,
etherToWei,
getUnits,
gweiToEther,
lamportToSol,
satoshiToBitcoin,
weiToEther,
} from "./converter.js";
describe("convertUnits", () => {
describe("ETH conversions", () => {
it("wei to ether", { timeout: 5000 }, () => {
expect(
convertUnits("1000000000000000000", "wei", "ether", "ETH")
).toBe("1");
});
it("ether to wei", { timeout: 5000 }, () => {
expect(convertUnits("1", "ether", "wei", "ETH")).toBe(
"1000000000000000000"
);
});
it("gwei to ether", { timeout: 5000 }, () => {
expect(convertUnits("1000000000", "gwei", "ether", "ETH")).toBe(
"1"
);
});
it("wei to gwei", { timeout: 5000 }, () => {
expect(convertUnits("1000000000", "wei", "gwei", "ETH")).toBe("1");
});
it("small wei to ether", { timeout: 5000 }, () => {
expect(convertUnits("21000", "gwei", "ether", "ETH")).toBe(
"0.000021"
);
});
it("same unit returns same value", { timeout: 5000 }, () => {
expect(convertUnits("42", "wei", "wei", "ETH")).toBe("42");
});
it("accepts bigint input", { timeout: 5000 }, () => {
expect(
convertUnits(1000000000000000000n, "wei", "ether", "ETH")
).toBe("1");
});
it("accepts decimal string by taking integer part", {
timeout: 5000,
}, () => {
// parseToBigInt drops the fractional part
expect(convertUnits("3.7", "wei", "wei", "ETH")).toBe("3");
});
it("treats sub-1 decimal input (e.g. '.5') as zero base units", {
timeout: 5000,
}, () => {
// Inputs of the form ".5" have an empty whole part; parseToBigInt
// returns 0n to match the upstream contract.
expect(convertUnits(".5", "wei", "wei", "ETH")).toBe("0");
});
});
describe("BTC conversions", () => {
it("satoshi to bitcoin", { timeout: 5000 }, () => {
expect(convertUnits("100000000", "satoshi", "bitcoin", "BTC")).toBe(
"1"
);
});
it("bitcoin to satoshi", { timeout: 5000 }, () => {
expect(convertUnits("1", "bitcoin", "satoshi", "BTC")).toBe(
"100000000"
);
});
it("small satoshi to bitcoin", { timeout: 5000 }, () => {
expect(convertUnits("1", "satoshi", "bitcoin", "BTC")).toBe(
"0.00000001"
);
});
it("millibitcoin to satoshi", { timeout: 5000 }, () => {
expect(convertUnits("1", "millibitcoin", "satoshi", "BTC")).toBe(
"100000"
);
});
it("negative satoshi → bitcoin formats sign", { timeout: 5000 }, () => {
expect(convertUnits(-100000000n, "satoshi", "bitcoin", "BTC")).toBe(
"-1"
);
});
it("negative fractional satoshi → bitcoin", { timeout: 5000 }, () => {
expect(convertUnits(-1n, "satoshi", "bitcoin", "BTC")).toBe(
"-0.00000001"
);
});
});
describe("SOL conversions", () => {
it("lamport to sol", { timeout: 5000 }, () => {
expect(convertUnits("1000000000", "lamport", "sol", "SOL")).toBe(
"1"
);
});
it("sol to lamport", { timeout: 5000 }, () => {
expect(convertUnits("1", "sol", "lamport", "SOL")).toBe(
"1000000000"
);
});
it("small lamport to sol", { timeout: 5000 }, () => {
expect(convertUnits("1", "lamport", "sol", "SOL")).toBe(
"0.000000001"
);
});
});
describe("errors", () => {
it("throws CRYPTOCURRENCY.UNKNOWN_CURRENCY on unknown currency", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
convertUnits("1", "wei", "ether", "XYZ");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptocurrencyError);
expect((caught as CryptocurrencyError).code).toBe(
CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY
);
expect((caught as CryptocurrencyError).currency).toBe("XYZ");
});
it("throws CRYPTOCURRENCY.UNKNOWN_UNIT on unknown from unit", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
convertUnits("1", "fake", "ether", "ETH");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptocurrencyError);
expect((caught as CryptocurrencyError).code).toBe(
CRYPTOCURRENCY_CODES.UNKNOWN_UNIT
);
expect((caught as CryptocurrencyError).currency).toBe("ETH");
expect((caught as CryptocurrencyError).unit).toBe("fake");
});
it("throws CRYPTOCURRENCY.UNKNOWN_UNIT on unknown to unit", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
convertUnits("1", "wei", "fake", "ETH");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptocurrencyError);
expect((caught as CryptocurrencyError).code).toBe(
CRYPTOCURRENCY_CODES.UNKNOWN_UNIT
);
expect((caught as CryptocurrencyError).unit).toBe("fake");
});
});
describe("case insensitivity", () => {
it("accepts lowercase currency", { timeout: 5000 }, () => {
expect(convertUnits("100000000", "satoshi", "bitcoin", "btc")).toBe(
"1"
);
});
});
});
describe("convenience functions", () => {
it("weiToEther", { timeout: 5000 }, () => {
expect(weiToEther("1000000000000000000")).toBe("1");
});
it("etherToWei", { timeout: 5000 }, () => {
expect(etherToWei("1")).toBe("1000000000000000000");
});
it("gweiToEther", { timeout: 5000 }, () => {
expect(gweiToEther("1000000000")).toBe("1");
});
it("satoshiToBitcoin", { timeout: 5000 }, () => {
expect(satoshiToBitcoin("100000000")).toBe("1");
});
it("bitcoinToSatoshi", { timeout: 5000 }, () => {
expect(bitcoinToSatoshi("1")).toBe("100000000");
});
it("lamportToSol", { timeout: 5000 }, () => {
expect(lamportToSol("1000000000")).toBe("1");
});
});
describe("getUnits", () => {
it("returns ETH units", { timeout: 5000 }, () => {
const units = getUnits("ETH");
expect(units.length).toBeGreaterThan(0);
expect(units.find((u) => u.name === "wei")).toBeDefined();
expect(units.find((u) => u.name === "ether")).toBeDefined();
});
it("returns BTC units", { timeout: 5000 }, () => {
const units = getUnits("BTC");
expect(units.find((u) => u.name === "satoshi")).toBeDefined();
expect(units.find((u) => u.name === "bitcoin")).toBeDefined();
});
it("returns SOL units", { timeout: 5000 }, () => {
const units = getUnits("SOL");
expect(units.find((u) => u.name === "lamport")).toBeDefined();
expect(units.find((u) => u.name === "sol")).toBeDefined();
});
it("returns empty for unknown currency", { timeout: 5000 }, () => {
expect(getUnits("XYZ")).toEqual([]);
});
});
describe("converter source", () => {
it("converter source has no node/bun imports (tier purity)", {
timeout: 5000,
}, () => {
const src = readFileSync(
new URL("./converter.ts", import.meta.url),
"utf8"
);
expect(src).not.toMatch(/from ['"]node:/);
expect(src).not.toMatch(/from ['"]bun:/);
expect(src).not.toMatch(
/from ['"](fs|path|stream|net|http|crypto)['"]/
);
});
});

View File

@@ -0,0 +1,172 @@
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
} from "../error-codes/error-codes.js";
import type { CryptoUnit } from "../types/crypto.types.js";
/** Ethereum unit definitions. */
const ETH_UNITS: Record<string, CryptoUnit> = {
wei: { name: "wei", decimals: 0, symbol: "wei" },
kwei: { name: "kwei", decimals: 3, symbol: "kwei" },
mwei: { name: "mwei", decimals: 6, symbol: "mwei" },
gwei: { name: "gwei", decimals: 9, symbol: "gwei" },
microether: { name: "microether", decimals: 12, symbol: "µETH" },
milliether: { name: "milliether", decimals: 15, symbol: "mETH" },
ether: { name: "ether", decimals: 18, symbol: "ETH" },
};
/** Bitcoin unit definitions. */
const BTC_UNITS: Record<string, CryptoUnit> = {
satoshi: { name: "satoshi", decimals: 0, symbol: "sat" },
microbitcoin: { name: "microbitcoin", decimals: 2, symbol: "µBTC" },
millibitcoin: { name: "millibitcoin", decimals: 5, symbol: "mBTC" },
bitcoin: { name: "bitcoin", decimals: 8, symbol: "BTC" },
};
/** Solana unit definitions. */
const SOL_UNITS: Record<string, CryptoUnit> = {
lamport: { name: "lamport", decimals: 0, symbol: "lamport" },
sol: { name: "sol", decimals: 9, symbol: "SOL" },
};
const UNIT_MAPS: Record<string, Record<string, CryptoUnit>> = {
ETH: ETH_UNITS,
BTC: BTC_UNITS,
SOL: SOL_UNITS,
};
/**
* Convert between cryptocurrency units. The `currency` argument is
* case-insensitive. Internally everything is `bigint` arithmetic; the
* returned string is a decimal representation with trailing zeros trimmed.
*
* Throws `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CURRENCY })`
* for currencies outside ETH/BTC/SOL, and
* `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_UNIT })` for
* unrecognised denominations.
*
* @example
* convertUnits('1', 'ether', 'wei', 'ETH') // '1000000000000000000'
* convertUnits('100000000', 'satoshi', 'bitcoin', 'BTC') // '1'
* convertUnits('1000000000', 'lamport', 'sol', 'SOL') // '1'
*/
export function convertUnits(
value: string | bigint,
fromUnit: string,
toUnit: string,
currency: string
): string {
const currencyKey = currency.toUpperCase();
const units = UNIT_MAPS[currencyKey];
if (!units) {
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY,
`Unknown currency: ${currency}. Supported: ${Object.keys(UNIT_MAPS).join(", ")}`,
{ currency }
);
}
const from = units[fromUnit];
if (!from) {
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_UNIT,
`Unknown unit '${fromUnit}' for ${currencyKey}`,
{ currency: currencyKey, unit: fromUnit }
);
}
const to = units[toUnit];
if (!to) {
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_UNIT,
`Unknown unit '${toUnit}' for ${currencyKey}`,
{ currency: currencyKey, unit: toUnit }
);
}
const baseValue = typeof value === "bigint" ? value : parseToBigInt(value);
if (from.decimals === to.decimals) {
return baseValue.toString();
}
if (from.decimals > to.decimals) {
// Converting from a coarser unit to a finer one (e.g. ether → wei) → multiply.
const factor = 10n ** BigInt(from.decimals - to.decimals);
return (baseValue * factor).toString();
}
// Converting from a finer unit to a coarser one (e.g. wei → ether) → divide.
return formatBigIntWithDecimals(baseValue, to.decimals - from.decimals);
}
/** Convert wei to ether (convenience function). */
export function weiToEther(wei: string | bigint): string {
return convertUnits(wei, "wei", "ether", "ETH");
}
/** Convert ether to wei (convenience function). */
export function etherToWei(ether: string | bigint): string {
return convertUnits(ether, "ether", "wei", "ETH");
}
/** Convert gwei to ether (convenience function). */
export function gweiToEther(gwei: string | bigint): string {
return convertUnits(gwei, "gwei", "ether", "ETH");
}
/** Convert satoshi to bitcoin (convenience function). */
export function satoshiToBitcoin(satoshi: string | bigint): string {
return convertUnits(satoshi, "satoshi", "bitcoin", "BTC");
}
/** Convert bitcoin to satoshi (convenience function). */
export function bitcoinToSatoshi(btc: string | bigint): string {
return convertUnits(btc, "bitcoin", "satoshi", "BTC");
}
/** Convert lamport to SOL (convenience function). */
export function lamportToSol(lamport: string | bigint): string {
return convertUnits(lamport, "lamport", "sol", "SOL");
}
/**
* Get all units for a currency. Returns `[]` for unrecognised currencies
* so callers can dispatch on length without try-catch.
*/
export function getUnits(currency: string): CryptoUnit[] {
const units = UNIT_MAPS[currency.toUpperCase()];
if (!units) return [];
return Object.values(units);
}
/**
* Parse a decimal string to bigint, taking the integer part only. Decimal
* sub-units in the input are ignored (the conversion is unit-relative so
* fractional sub-units would not be representable in `bigint`).
*/
function parseToBigInt(value: string): bigint {
const trimmed = value.trim();
const dotIdx = trimmed.indexOf(".");
if (dotIdx === -1) return BigInt(trimmed);
const whole = trimmed.slice(0, dotIdx);
// Empty whole (input like ".5") would throw BigInt(""); upstream
// contract treats sub-1 inputs as 0 in base units.
return whole === "" ? 0n : BigInt(whole);
}
/** Format a bigint with `decimalPlaces` decimal places, trimming trailing zeros. */
function formatBigIntWithDecimals(
value: bigint,
decimalPlaces: number
): string {
const negative = value < 0n;
const abs = negative ? -value : value;
const factor = 10n ** BigInt(decimalPlaces);
const whole = abs / factor;
const frac = abs % factor;
const fracStr = frac.toString().padStart(decimalPlaces, "0");
const trimmed = fracStr.replace(/0+$/, "") || "0";
const sign = negative ? "-" : "";
if (trimmed === "0") return `${sign}${whole}`;
return `${sign}${whole}.${trimmed}`;
}

View File

@@ -0,0 +1,10 @@
export {
bitcoinToSatoshi,
convertUnits,
etherToWei,
getUnits,
gweiToEther,
lamportToSol,
satoshiToBitcoin,
weiToEther,
} from "./converter.js";

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
/**
* Asserts that every subpath advertised in `package.json` `exports`
* resolves at runtime. This catches drift between the manifest and the
* actual file layout (rename a file, forget the export entry, and this
* test fails).
*
* We exercise the in-source TypeScript modules via relative imports
* because `bun test` runs against `src/` directly — the `dist/`
* subpaths would only resolve after `vite build`, which is a separate
* pipeline stage. The structural shape is the same.
*/
const MODULES: ReadonlyArray<{
subpath: string;
loader: () => Promise<unknown>;
}> = [
{
subpath: "./address/validator",
loader: () => import("../src/address/validator.js"),
},
{
subpath: "./chains/chain-data",
loader: () => import("../src/chains/chain-data.js"),
},
{
subpath: "./chains/chain-registry",
loader: () => import("../src/chains/chain-registry.js"),
},
{
subpath: "./chains/registry",
loader: () => import("../src/chains/registry.js"),
},
{
subpath: "./error-codes/error-codes",
loader: () => import("../src/error-codes/error-codes.js"),
},
{
subpath: "./gas/gas-estimator",
loader: () => import("../src/gas/gas-estimator.js"),
},
{
subpath: "./tokens/token-data",
loader: () => import("../src/tokens/token-data.js"),
},
{
subpath: "./tokens/token-registry",
loader: () => import("../src/tokens/token-registry.js"),
},
{
subpath: "./transaction/tx-decoder",
loader: () => import("../src/transaction/tx-decoder.js"),
},
{
subpath: "./types/crypto.types",
loader: () => import("../src/types/crypto.types.js"),
},
{
subpath: "./units/converter",
loader: () => import("../src/units/converter.js"),
},
];
describe("subpath exports", () => {
it("every package.json subpath export resolves to a real module", {
timeout: 30000,
}, async () => {
for (const { subpath, loader } of MODULES) {
const mod = await loader();
expect(
mod,
`expected ${subpath} to resolve to a module record`
).toBeTruthy();
// The types subpath is pure types — it compiles to an empty module record.
// Every other subpath must have at least one runtime export.
if (subpath !== "./types/crypto.types") {
expect(
Object.keys(mod as Record<string, unknown>).length,
`expected ${subpath} to have at least one named export`
).toBeGreaterThan(0);
}
}
});
});

View File

@@ -0,0 +1,5 @@
// Polyfills for test environment — none required. The package is universal
// and uses only standard ECMAScript built-ins (BigInt, Map, RegExp, Object,
// Array, String, Number). This file exists to mirror the sibling package layout
// and to give vitest a stable setup file path.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/cryptocurrency",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: ["@suaveplan/error", "@suaveplan/types", "tslib"],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,68 @@
{
"name": "@suaveplan/currency-provider-binance",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./binance/binance": {
"import": {
"types": "./dist/binance/binance.d.ts",
"default": "./dist/binance/binance.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/currency": "workspace:^",
"@suaveplan/currency-providers": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

@@ -0,0 +1,412 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import {
BINANCE_DEFAULT_BASE_URL,
BINANCE_PROVIDER_NAME,
BinanceProvider,
} from "./binance.js";
interface FetchCall {
readonly url: string;
readonly init: RequestInit | undefined;
}
function recordingFetch(
handler: (call: FetchCall) => Promise<Response> | Response
): { fetch: FetchLike; calls: FetchCall[] } {
const calls: FetchCall[] = [];
const fetchImpl: FetchLike = async (
url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
const call: FetchCall = { url: String(url), init };
calls.push(call);
return await handler(call);
};
return { fetch: fetchImpl, calls };
}
const SAMPLE_TICKERS = [
{ symbol: "BTCUSDT", price: "67000.00" },
{ symbol: "ETHUSDT", price: "3500.00" },
{ symbol: "SOLUSDT", price: "150.00" },
{ symbol: "BTCEUR", price: "62000.00" },
{ symbol: "ETHEUR", price: "3200.00" },
];
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("BinanceProvider — identity", () => {
it("exposes the stable provider name and capability flags", {
timeout: 5000,
}, () => {
const provider = new BinanceProvider();
expect(provider.name).toBe(BINANCE_PROVIDER_NAME);
expect(BINANCE_PROVIDER_NAME).toBe("binance");
expect(provider.supportsCrypto).toBe(true);
expect(provider.supportsFiat).toBe(false);
});
it("exports the default REST base URL constant", { timeout: 5000 }, () => {
expect(BINANCE_DEFAULT_BASE_URL).toBe("https://api.binance.com/api/v3");
});
});
describe("BinanceProvider — fetchRates happy path", () => {
it("defaults base to USDT and decodes USDT-quoted pairs to 1/price", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(SAMPLE_TICKERS)
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates();
expect(result.base).toBe("USDT");
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
expect(result.rates.SOL).toBeCloseTo(1 / 150, 12);
expect(typeof result.timestamp).toBe("number");
expect(calls[0]?.url).toBe(`${BINANCE_DEFAULT_BASE_URL}/ticker/price`);
});
it("filters by an explicit base suffix (EUR)", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse(SAMPLE_TICKERS));
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "EUR" });
expect(result.base).toBe("EUR");
expect(result.rates.BTC).toBeCloseTo(1 / 62000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3200, 12);
expect(result.rates.SOL).toBeUndefined();
});
it("filters to the requested targets when present", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse(SAMPLE_TICKERS));
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({
base: "USDT",
targets: ["BTC", "ETH"],
});
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "ETH"]);
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
});
it("uses a custom baseUrl when provided", { timeout: 5000 }, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(SAMPLE_TICKERS)
);
const provider = new BinanceProvider({
fetch,
baseUrl: "https://custom.binance.example/api/v3",
});
await provider.fetchRates();
expect(calls[0]?.url).toBe(
"https://custom.binance.example/api/v3/ticker/price"
);
});
});
describe("BinanceProvider — sanitisation", () => {
it("skips zero-price tickers", { timeout: 5000 }, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([
{ symbol: "BTCUSDT", price: "67000.00" },
{ symbol: "FAKUSDT", price: "0.00" },
])
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "USDT" });
expect(result.rates.BTC).toBeDefined();
expect(result.rates.FAK).toBeUndefined();
});
it("skips negative-price tickers", { timeout: 5000 }, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([{ symbol: "BADUSDT", price: "-1.00" }])
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "USDT" });
expect(result.rates.BAD).toBeUndefined();
});
it("skips tickers whose price is not finite", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([{ symbol: "NANUSDT", price: "not-a-number" }])
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "USDT" });
expect(result.rates.NAN).toBeUndefined();
});
it("skips a ticker whose symbol equals the suffix exactly", {
timeout: 5000,
}, async () => {
// `USDTUSDT` would decode to an empty key — defensively skipped.
const { fetch } = recordingFetch(() =>
jsonResponse([
{ symbol: "USDT", price: "1.00" },
{ symbol: "BTCUSDT", price: "67000.00" },
])
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "USDT" });
expect(Object.hasOwn(result.rates, "")).toBe(false);
expect(result.rates.BTC).toBeDefined();
});
});
describe("BinanceProvider — error paths", () => {
it("propagates HTTP_ERROR raised by the shared httpRequest helper", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("upstream down", {
status: 503,
statusText: "Service Unavailable",
})
);
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
expect(err.status).toBe(503);
expect(err.providerName).toBe(BINANCE_PROVIDER_NAME);
}
});
it("raises PARSE_ERROR when the response body is not JSON", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response("not json {{{", { status: 200 })
);
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
expect(err.providerName).toBe(BINANCE_PROVIDER_NAME);
expect(err.cause).toBeInstanceOf(Error);
}
});
it("raises PARSE_ERROR and omits cause when the rejection is non-Error", {
timeout: 5000,
}, async () => {
// Body whose .json() rejects with a non-Error throwable — exercises
// the `cause instanceof Error ? cause : undefined` branch.
const badResponse: Response = new Response("ignored", { status: 200 });
Object.defineProperty(badResponse, "json", {
value: async () => {
const failure: unknown = "string-rejection";
throw failure;
},
});
const { fetch } = recordingFetch(() => badResponse);
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
expect(err.cause).toBeUndefined();
}
});
it("raises UPSTREAM_ERROR when the payload is not an array", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({ error: "rate limited" })
);
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
expect(err.providerName).toBe(BINANCE_PROVIDER_NAME);
}
});
it("raises UPSTREAM_ERROR when a row is null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse([null]));
const provider = new BinanceProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR when a row has the wrong shape", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([{ symbol: "BTCUSDT", price: 67000 }])
);
const provider = new BinanceProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR when a row is missing the symbol field", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([{ price: "1.00" }])
);
const provider = new BinanceProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UNKNOWN_CURRENCY when a requested target is not listed", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse(SAMPLE_TICKERS));
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates({
base: "USDT",
targets: ["BTC", "DOES_NOT_EXIST"],
});
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
expect(err.providerName).toBe(BINANCE_PROVIDER_NAME);
expect(err.upstreamMessage).toBe("DOES_NOT_EXIST");
}
});
});
describe("BinanceProvider — abort + timeout", () => {
it("forwards a caller AbortSignal through to httpRequest", {
timeout: 5000,
}, async () => {
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
const controller = new AbortController();
const provider = new BinanceProvider({ fetch: fetchImpl });
const promise = provider.fetchRates({ signal: controller.signal });
controller.abort(new Error("caller cancelled"));
await expect(promise).rejects.toThrow(/caller cancelled/);
});
it("forwards timeoutMs through to httpRequest", {
timeout: 5000,
}, async () => {
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
const provider = new BinanceProvider({
fetch: fetchImpl,
timeoutMs: 5,
});
await expect(provider.fetchRates()).rejects.toThrow();
});
});
describe("BinanceProvider — default fetch wiring", () => {
it("falls back to globalThis.fetch when no FetchLike is injected", {
timeout: 5000,
}, async () => {
const originalFetch = globalThis.fetch;
let observedUrl = "";
const stubFetch: typeof globalThis.fetch = async (input, init) => {
observedUrl = String(input);
void init;
return jsonResponse(SAMPLE_TICKERS);
};
globalThis.fetch = stubFetch;
try {
const provider = new BinanceProvider();
const result = await provider.fetchRates({ base: "USDT" });
expect(result.rates.BTC).toBeDefined();
expect(observedUrl).toBe(
`${BINANCE_DEFAULT_BASE_URL}/ticker/price`
);
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -0,0 +1,156 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
/** Default Binance spot REST base URL — public endpoint, no API key. */
export const BINANCE_DEFAULT_BASE_URL = "https://api.binance.com/api/v3";
/** Stable provider identifier surfaced through {@link CurrencyProvider.name}. */
export const BINANCE_PROVIDER_NAME = "binance";
/** Wire-shape of a single ticker row in the `/ticker/price` response. */
interface BinanceTickerRow {
readonly symbol: string;
readonly price: string;
}
function ensureTickerArray(payload: unknown): readonly BinanceTickerRow[] {
if (!Array.isArray(payload)) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
"Binance /ticker/price returned a non-array payload",
{ providerName: BINANCE_PROVIDER_NAME }
);
}
for (const row of payload) {
if (
row === null ||
typeof row !== "object" ||
typeof (row as { symbol?: unknown }).symbol !== "string" ||
typeof (row as { price?: unknown }).price !== "string"
) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
"Binance /ticker/price returned a malformed ticker row",
{ providerName: BINANCE_PROVIDER_NAME }
);
}
}
return payload as readonly BinanceTickerRow[];
}
function decodeRates(
rows: readonly BinanceTickerRow[],
suffix: string
): Record<string, number> {
const rates: Record<string, number> = {};
for (const ticker of rows) {
if (!ticker.symbol.endsWith(suffix)) continue;
if (ticker.symbol.length === suffix.length) continue;
const symbol = ticker.symbol.slice(0, -suffix.length);
const price = Number.parseFloat(ticker.price);
if (Number.isFinite(price) && price > 0) {
rates[symbol] = 1 / price;
}
}
return rates;
}
function filterTargets(
rates: Readonly<Record<string, number>>,
targets: readonly string[]
): Record<string, number> {
const out: Record<string, number> = {};
for (const target of targets) {
const value = rates[target];
if (value === undefined) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`Binance does not list pair ${target} against requested base`,
{
providerName: BINANCE_PROVIDER_NAME,
upstreamMessage: target,
}
);
}
out[target] = value;
}
return out;
}
/**
* Binance public-ticker crypto-rates provider. Implements the
* `CurrencyProvider` contract on top of Binance's free, key-less
* `/api/v3/ticker/price` endpoint. Returns the spot mid-price of every
* listed trading pair whose symbol ends in the requested base suffix
* (e.g. `BTCUSDT` → `BTC` when base is `USDT`), inverted to
* `1 / price` so the rate map answers "1 base = N units of asset".
*/
export class BinanceProvider implements CurrencyProvider {
public readonly name = BINANCE_PROVIDER_NAME;
public readonly supportsCrypto = true;
public readonly supportsFiat = false;
private readonly baseUrl: string;
private readonly fetchImpl: NonNullable<NoKeyConfig["fetch"]>;
private readonly timeoutMs: number | undefined;
public constructor(config?: NoKeyConfig & { readonly timeoutMs?: number }) {
this.baseUrl = config?.baseUrl ?? BINANCE_DEFAULT_BASE_URL;
const injected = config?.fetch;
this.fetchImpl =
injected ?? ((url, init) => globalThis.fetch(url, init));
this.timeoutMs = config?.timeoutMs;
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const base = options?.base ?? "USDT";
const url = `${this.baseUrl}/ticker/price`;
const response = await httpRequest({
url,
fetch: this.fetchImpl,
providerName: BINANCE_PROVIDER_NAME,
...(options?.signal !== undefined
? { signal: options.signal }
: {}),
...(this.timeoutMs !== undefined
? { timeoutMs: this.timeoutMs }
: {}),
});
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"Binance /ticker/price returned a non-JSON body",
{
providerName: BINANCE_PROVIDER_NAME,
cause: cause instanceof Error ? cause : undefined,
}
);
}
const rows = ensureTickerArray(payload);
const rates = decodeRates(rows, base);
const finalRates =
options?.targets !== undefined
? filterTargets(rates, options.targets)
: rates;
return { base, rates: finalRates, timestamp: Date.now() };
}
}

View File

@@ -0,0 +1,5 @@
export {
BINANCE_DEFAULT_BASE_URL,
BINANCE_PROVIDER_NAME,
BinanceProvider,
} from "./binance.js";

View File

@@ -0,0 +1,5 @@
export {
BINANCE_DEFAULT_BASE_URL,
BINANCE_PROVIDER_NAME,
BinanceProvider,
} from "./binance/binance.js";

View File

@@ -0,0 +1,6 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `Response`, `Number.parseFloat`, `Date.now`, the WHATWG `fetch`
// surface, and an injected `FetchLike` closure — all available in every
// supported runtime (Node 18+, Bun, browsers, Web Workers). This file exists
// so vitest has a stable setup file path mirroring the sibling-package layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/currency-provider-binance",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/currency",
"@suaveplan/currency-providers",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,74 @@
{
"name": "@suaveplan/currency-provider-coingecko",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./coingecko/coingecko": {
"import": {
"types": "./dist/coingecko/coingecko.d.ts",
"default": "./dist/coingecko/coingecko.js"
}
},
"./coin-ids/coin-ids": {
"import": {
"types": "./dist/coin-ids/coin-ids.d.ts",
"default": "./dist/coin-ids/coin-ids.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/currency": "workspace:^",
"@suaveplan/currency-providers": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { COIN_IDS, getCoinGeckoId } from "./coin-ids.js";
describe("getCoinGeckoId", () => {
it("returns the CoinGecko id for known symbols regardless of case", {
timeout: 5000,
}, () => {
expect(getCoinGeckoId("BTC")).toBe("bitcoin");
expect(getCoinGeckoId("btc")).toBe("bitcoin");
expect(getCoinGeckoId("Eth")).toBe("ethereum");
expect(getCoinGeckoId("SOL")).toBe("solana");
expect(getCoinGeckoId("matic")).toBe("matic-network");
});
it("returns undefined for unknown symbols", { timeout: 5000 }, () => {
expect(getCoinGeckoId("ZZZ")).toBeUndefined();
expect(getCoinGeckoId("")).toBeUndefined();
expect(getCoinGeckoId("not-a-symbol")).toBeUndefined();
});
});
describe("COIN_IDS", () => {
it("covers the top-16 curated set with uppercase keys", {
timeout: 5000,
}, () => {
const expectedKeys = [
"BTC",
"ETH",
"SOL",
"USDT",
"USDC",
"BNB",
"XRP",
"ADA",
"DOGE",
"DOT",
"AVAX",
"MATIC",
"LINK",
"UNI",
"ATOM",
"LTC",
];
expect(Object.keys(COIN_IDS).sort()).toEqual(expectedKeys.sort());
for (const key of Object.keys(COIN_IDS)) {
expect(key).toBe(key.toUpperCase());
}
});
it("is frozen — runtime mutation is rejected", { timeout: 5000 }, () => {
expect(Object.isFrozen(COIN_IDS)).toBe(true);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Curated mapping from uppercase crypto symbol to the CoinGecko coin id
* required by the `/simple/price` endpoint. CoinGecko's API is keyed by an
* opaque coin id (`bitcoin`, `ethereum`, …) rather than the ticker symbol,
* so every request the provider issues SHALL pass values from this map.
*
* The set covers the top 16 cryptocurrencies by market cap at landing time.
* Consumers needing additional symbols register them via their own wrapper
* provider; we deliberately do not auto-fetch CoinGecko's `/coins/list`
* because that endpoint is heavy (~10k entries) and the resulting bundle
* would defeat the purpose of having a slim sibling.
*/
export const COIN_IDS: Readonly<Record<string, string>> = Object.freeze({
BTC: "bitcoin",
ETH: "ethereum",
SOL: "solana",
USDT: "tether",
USDC: "usd-coin",
BNB: "binancecoin",
XRP: "ripple",
ADA: "cardano",
DOGE: "dogecoin",
DOT: "polkadot",
AVAX: "avalanche-2",
MATIC: "matic-network",
LINK: "chainlink",
UNI: "uniswap",
ATOM: "cosmos",
LTC: "litecoin",
});
/**
* Case-insensitive lookup of the CoinGecko coin id for a ticker symbol.
* Returns `undefined` when the symbol is not present in the curated map;
* callers that issue requests SHOULD treat that result as an
* `UNKNOWN_CURRENCY` condition rather than passing the raw symbol upstream.
*/
export function getCoinGeckoId(symbol: string): string | undefined {
return COIN_IDS[symbol.toUpperCase()];
}

View File

@@ -0,0 +1,4 @@
export {
COIN_IDS,
getCoinGeckoId,
} from "./coin-ids.js";

View File

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

View File

@@ -0,0 +1,364 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import { CoinGeckoProvider } from "./coingecko.js";
interface FetchCall {
url: string;
init: RequestInit | undefined;
}
function recordingFetch(
handler: (call: FetchCall) => Response | Promise<Response>
): { fetch: FetchLike; calls: FetchCall[] } {
const calls: FetchCall[] = [];
const fetchImpl: FetchLike = async (
url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
const call: FetchCall = { url: String(url), init };
calls.push(call);
return await handler(call);
};
return { fetch: fetchImpl, calls };
}
const USD_BODY = {
bitcoin: { usd: 67000 },
ethereum: { usd: 3500 },
solana: { usd: 150 },
tether: { usd: 1 },
"usd-coin": { usd: 1 },
};
function ok(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
describe("CoinGeckoProvider", () => {
it("exposes stable name and supports flags", { timeout: 5000 }, () => {
const provider = new CoinGeckoProvider();
expect(provider.name).toBe("coingecko");
expect(provider.supportsCrypto).toBe(true);
expect(provider.supportsFiat).toBe(true);
});
it("issues request to default endpoint with vs_currencies from base", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
await provider.fetchRates({ base: "USD" });
const url = calls[0]?.url ?? "";
expect(
url.startsWith("https://api.coingecko.com/api/v3/simple/price?ids=")
).toBe(true);
expect(url.endsWith("&vs_currencies=usd")).toBe(true);
});
it("honours custom baseUrl override", { timeout: 5000 }, async () => {
const { fetch, calls } = recordingFetch(() =>
ok({ bitcoin: { eur: 62000 } })
);
const provider = new CoinGeckoProvider({
baseUrl: "https://custom.example/v3",
fetch,
});
await provider.fetchRates({ base: "EUR" });
expect(
calls[0]?.url.startsWith("https://custom.example/v3/simple/price")
).toBe(true);
});
it("attaches x-cg-pro-api-key header when apiKey is provided", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ apiKey: "secret", fetch });
await provider.fetchRates();
const headers = calls[0]?.init?.headers as
| Record<string, string>
| undefined;
expect(headers).toBeDefined();
expect(headers?.["x-cg-pro-api-key"]).toBe("secret");
});
it("omits x-cg-pro-api-key header when apiKey is absent", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
await provider.fetchRates();
// init.headers should not exist at all on the keyless branch
expect(calls[0]?.init?.headers).toBeUndefined();
});
it("inverts upstream prices into base→crypto rates", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
const result = await provider.fetchRates({ base: "USD" });
expect(result.base).toBe("USD");
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
expect(result.rates.SOL).toBeCloseTo(1 / 150, 12);
expect(result.timestamp).toBeTypeOf("number");
});
it("skips known targets whose upstream price is absent", {
timeout: 5000,
}, async () => {
// ATOM is a known symbol (passes UNKNOWN_CURRENCY gate) but
// upstream returned only bitcoin — the filter branch where
// allRates[target] is undefined must be exercised.
const { fetch } = recordingFetch(() => ok({ bitcoin: { usd: 67000 } }));
const provider = new CoinGeckoProvider({ fetch });
const result = await provider.fetchRates({
base: "USD",
targets: ["BTC", "ATOM"],
});
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ATOM).toBeUndefined();
expect(Object.keys(result.rates)).toEqual(["BTC"]);
});
it("filters rates by options.targets", { timeout: 5000 }, async () => {
const { fetch } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
const result = await provider.fetchRates({
base: "USD",
targets: ["BTC", "ETH"],
});
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "ETH"]);
});
it("omits symbols whose upstream price is missing or non-positive", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
ok({
bitcoin: { usd: 67000 },
ethereum: { usd: 0 }, // non-positive — skip
solana: {}, // missing vs_currency — skip
"not-a-known-id": { usd: 5 }, // unknown id — skip
})
);
const provider = new CoinGeckoProvider({ fetch });
const result = await provider.fetchRates({ base: "USD" });
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeUndefined();
expect(result.rates.SOL).toBeUndefined();
});
it("raises HTTP_ERROR on non-2xx response", { timeout: 5000 }, async () => {
const { fetch } = recordingFetch(
() =>
new Response("", {
status: 429,
statusText: "Too Many Requests",
})
);
const provider = new CoinGeckoProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
expect(err.providerName).toBe("coingecko");
expect(err.status).toBe(429);
}
});
it("raises PARSE_ERROR when body is not valid JSON", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response("not json at all", { status: 200 })
);
const provider = new CoinGeckoProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
expect(err.providerName).toBe("coingecko");
expect(err.cause).toBeInstanceOf(Error);
}
});
it("raises PARSE_ERROR even when JSON parser throws a non-Error", {
timeout: 5000,
}, async () => {
const fetchImpl: FetchLike = async (): Promise<Response> => {
// Build a Response-like whose .json() rejects with a non-Error throwable.
const fake = {
ok: true,
status: 200,
statusText: "OK",
json: async (): Promise<unknown> => {
const literal: unknown = "string-throwable";
throw literal;
},
};
return fake as unknown as Response;
};
const provider = new CoinGeckoProvider({ fetch: fetchImpl });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
// cause is only attached when the thrown value was an Error
expect(err.cause).toBeUndefined();
}
});
it("raises UPSTREAM_ERROR when body is the wrong shape (array)", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => ok([1, 2, 3]));
const provider = new CoinGeckoProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
expect(err.providerName).toBe("coingecko");
}
});
it("raises UPSTREAM_ERROR when body inner value is not an object", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
ok({ bitcoin: "not-an-object" })
);
const provider = new CoinGeckoProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR when body inner value is null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => ok({ bitcoin: null }));
const provider = new CoinGeckoProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR when body is null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => ok(null));
const provider = new CoinGeckoProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UNKNOWN_CURRENCY when a target has no CoinGecko mapping", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
try {
await provider.fetchRates({ base: "USD", targets: ["BTC", "ZZZ"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
expect(err.providerName).toBe("coingecko");
expect(err.message).toContain("ZZZ");
}
// No HTTP call was issued — the gate fires before httpRequest.
expect(calls.length).toBe(0);
});
it("forwards options.signal so caller can abort in flight", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
};
const provider = new CoinGeckoProvider({ fetch: fetchImpl });
const promise = provider.fetchRates({ signal: controller.signal });
controller.abort(new Error("caller cancelled"));
await expect(promise).rejects.toThrow(/caller cancelled/);
});
it("defaults to USD base and globalThis.fetch when no options/config supplied", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const calls: FetchCall[] = [];
globalThis.fetch = (async (
url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
calls.push({ url: String(url), init });
return ok(USD_BODY);
}) as typeof globalThis.fetch;
try {
const provider = new CoinGeckoProvider();
const result = await provider.fetchRates();
expect(result.base).toBe("USD");
expect(calls[0]?.url.endsWith("&vs_currencies=usd")).toBe(true);
} finally {
globalThis.fetch = original;
}
});
});

View File

@@ -0,0 +1,181 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
import { COIN_IDS, getCoinGeckoId } from "../coin-ids/coin-ids.js";
const PROVIDER_NAME = "coingecko";
const DEFAULT_BASE_URL = "https://api.coingecko.com/api/v3";
const PRO_API_KEY_HEADER = "x-cg-pro-api-key";
const ALL_IDS = Object.values(COIN_IDS).join(",");
/**
* Configuration accepted by {@link CoinGeckoProvider}. Extends the shared
* `NoKeyConfig` (optional `baseUrl` + injectable `fetch`) and adds the
* optional Pro-tier `apiKey`. Both the free public tier and the Pro tier
* use the same endpoint shape, so the key is genuinely optional rather
* than mutually exclusive — omitting it issues a keyless request,
* supplying it attaches the `x-cg-pro-api-key` header.
*/
export interface CoinGeckoConfig extends NoKeyConfig {
/**
* Optional CoinGecko Pro API key. When supplied, every outbound
* request carries `x-cg-pro-api-key: <key>`. Absent → free public
* tier (10–30 calls/min, no key).
*/
readonly apiKey?: string;
}
type CoinGeckoResponseShape = Record<string, Record<string, number>>;
function isResponseShape(value: unknown): value is CoinGeckoResponseShape {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
for (const inner of Object.values(value)) {
if (
typeof inner !== "object" ||
inner === null ||
Array.isArray(inner)
) {
return false;
}
}
return true;
}
function assertTargetsKnown(targets: readonly string[] | undefined): void {
if (!targets) return;
for (const target of targets) {
if (getCoinGeckoId(target) === undefined) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`${PROVIDER_NAME}: no CoinGecko id for target symbol "${target}"`,
{ providerName: PROVIDER_NAME }
);
}
}
}
async function parseJsonBody(response: Response): Promise<unknown> {
try {
return await response.json();
} catch (cause) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
`${PROVIDER_NAME}: failed to parse response body as JSON`,
{
providerName: PROVIDER_NAME,
cause: cause instanceof Error ? cause : undefined,
}
);
}
}
function buildRates(
data: CoinGeckoResponseShape,
vsCurrency: string,
targets: readonly string[] | undefined
): Record<string, number> {
const idToSymbol = new Map<string, string>();
for (const [symbol, id] of Object.entries(COIN_IDS)) {
idToSymbol.set(id, symbol);
}
const allRates: Record<string, number> = {};
for (const [id, prices] of Object.entries(data)) {
const symbol = idToSymbol.get(id);
const price = prices[vsCurrency];
if (symbol !== undefined && typeof price === "number" && price > 0) {
allRates[symbol] = 1 / price;
}
}
if (!targets) return allRates;
const filtered: Record<string, number> = {};
for (const target of targets) {
const value = allRates[target];
if (value !== undefined) {
filtered[target] = value;
}
}
return filtered;
}
/**
* CoinGecko crypto-rate provider. Implements the `CurrencyProvider`
* contract from `@suaveplan/currency` against the public `/simple/price`
* endpoint, routes every request through the shared `httpRequest`
* helper from `@suaveplan/currency-providers`, and raises typed
* `CurrencyProviderError` on every failure path.
*
* Both free and Pro tiers share the same URL shape; passing `apiKey`
* attaches the `x-cg-pro-api-key` header, omitting it issues a keyless
* public-tier request.
*/
export class CoinGeckoProvider implements CurrencyProvider {
public readonly name = PROVIDER_NAME;
public readonly supportsCrypto = true;
public readonly supportsFiat = true;
private readonly baseUrl: string;
private readonly fetchImpl: FetchLike;
private readonly headers: Readonly<Record<string, string>> | undefined;
public constructor(config: CoinGeckoConfig = {}) {
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.fetchImpl = config.fetch ?? (globalThis.fetch as FetchLike);
this.headers =
config.apiKey !== undefined
? Object.freeze({ [PRO_API_KEY_HEADER]: config.apiKey })
: undefined;
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const base = options?.base ?? "USD";
const vsCurrency = base.toLowerCase();
assertTargetsKnown(options?.targets);
const url = `${this.baseUrl}/simple/price?ids=${ALL_IDS}&vs_currencies=${vsCurrency}`;
const init: RequestInit | undefined =
this.headers !== undefined
? { headers: { ...this.headers } }
: undefined;
const response = await httpRequest({
url,
fetch: this.fetchImpl,
providerName: PROVIDER_NAME,
...(init !== undefined ? { init } : {}),
...(options?.signal !== undefined
? { signal: options.signal }
: {}),
});
const body = await parseJsonBody(response);
if (!isResponseShape(body)) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
`${PROVIDER_NAME}: response body did not match Record<string, Record<string, number>>`,
{ providerName: PROVIDER_NAME }
);
}
return {
base,
rates: buildRates(body, vsCurrency, options?.targets),
timestamp: Date.now(),
};
}
}

View File

@@ -0,0 +1,2 @@
export type { CoinGeckoConfig } from "./coingecko.js";
export { CoinGeckoProvider } from "./coingecko.js";

View File

@@ -0,0 +1,5 @@
export { COIN_IDS, getCoinGeckoId } from "./coin-ids/coin-ids.js";
export {
type CoinGeckoConfig,
CoinGeckoProvider,
} from "./coingecko/coingecko.js";

View File

@@ -0,0 +1,7 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `URLSearchParams`, `Response`, `Date.now`, `Map`, `Object.entries`,
// and the injected `FetchLike` (default `globalThis.fetch`) — all available in
// every supported runtime (Node 18+, Bun, browsers, Web Workers). This file
// exists to give vitest a stable setup file path mirroring the sibling-package
// layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/currency-provider-coingecko",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/currency",
"@suaveplan/currency-providers",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,68 @@
{
"name": "@suaveplan/currency-provider-coinmarketcap",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./coinmarketcap/coinmarketcap": {
"import": {
"types": "./dist/coinmarketcap/coinmarketcap.d.ts",
"default": "./dist/coinmarketcap/coinmarketcap.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/currency": "workspace:^",
"@suaveplan/currency-providers": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

@@ -0,0 +1,533 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import { CoinMarketCapProvider } from "./coinmarketcap.js";
interface FetchCall {
url: string;
init: RequestInit | undefined;
}
function recordingFetch(
handler: (call: FetchCall) => Promise<Response> | Response
): { fetch: FetchLike; calls: FetchCall[] } {
const calls: FetchCall[] = [];
const fetchImpl: FetchLike = async (
url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
const call: FetchCall = { url: String(url), init };
calls.push(call);
return await handler(call);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
const API_KEY = "test-key";
describe("CoinMarketCapProvider — identity", () => {
it("exposes stable identity fields", { timeout: 5000 }, () => {
const provider = new CoinMarketCapProvider({ apiKey: API_KEY });
expect(provider.name).toBe("coinmarketcap");
expect(provider.supportsCrypto).toBe(true);
expect(provider.supportsFiat).toBe(false);
expect(
(provider as CoinMarketCapProvider & { supportsVirtual?: boolean })
.supportsVirtual
).toBeUndefined();
});
});
describe("CoinMarketCapProvider — constructor validation", () => {
it("raises MISSING_CONFIG when apiKey is empty", { timeout: 5000 }, () => {
try {
new CoinMarketCapProvider({ apiKey: "" });
expect.fail("constructor should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.MISSING_CONFIG);
expect(err.providerName).toBe("coinmarketcap");
}
});
it("raises MISSING_CONFIG when apiKey is whitespace", {
timeout: 5000,
}, () => {
expect(() => new CoinMarketCapProvider({ apiKey: " " })).toThrow(
CurrencyProviderError
);
});
});
describe("CoinMarketCapProvider — fetchRates request shape", () => {
it("issues an authenticated GET to /cryptocurrency/quotes/latest", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
data: {
BTC: { quote: { EUR: { price: 40000 } } },
ETH: { quote: { EUR: { price: 2000 } } },
},
status: { error_code: 0 },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
const result = await provider.fetchRates({
base: "EUR",
targets: ["BTC", "ETH"],
});
expect(result.base).toBe("EUR");
expect(result.rates.BTC).toBeCloseTo(1 / 40000);
expect(result.rates.ETH).toBeCloseTo(1 / 2000);
expect(typeof result.timestamp).toBe("number");
expect(calls.length).toBe(1);
const call = calls[0];
expect(call?.url).toBe(
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH&convert=EUR"
);
expect(call?.init?.method).toBe("GET");
const headers = call?.init?.headers as
| Record<string, string>
| undefined;
expect(headers?.["X-CMC_PRO_API_KEY"]).toBe(API_KEY);
expect(headers?.Accept).toBe("application/json");
});
it("defaults to USD + top-10 symbols when no options are given", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await provider.fetchRates();
expect(calls[0]?.url).toBe(
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT&convert=USD"
);
});
it("defaults to top-10 symbols when targets is an empty array", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await provider.fetchRates({ targets: [] });
expect(calls[0]?.url).toContain(
"symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT"
);
});
it("forwards the caller AbortSignal to the injected fetch", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch: fetchImpl,
});
const promise = provider.fetchRates({
signal: controller.signal,
});
controller.abort(new Error("caller cancelled"));
await expect(promise).rejects.toThrow(/caller cancelled/);
});
it("honours a custom baseUrl", { timeout: 5000 }, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
baseUrl: "https://sandbox-api.coinmarketcap.com/v1",
fetch,
});
await provider.fetchRates({ targets: ["BTC"] });
expect(calls[0]?.url).toBe(
"https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC&convert=USD"
);
});
});
describe("CoinMarketCapProvider — globalThis.fetch fallback", () => {
const originalFetch = globalThis.fetch;
let stubCalled: { url: string } | undefined;
beforeEach(() => {
stubCalled = undefined;
globalThis.fetch = (async (
url: string | URL | Request,
_init?: RequestInit
): Promise<Response> => {
stubCalled = { url: String(url) };
return jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
});
}) as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("falls back to globalThis.fetch when fetch is not injected", {
timeout: 5000,
}, async () => {
const provider = new CoinMarketCapProvider({ apiKey: API_KEY });
const result = await provider.fetchRates({ targets: ["BTC"] });
expect(stubCalled?.url).toContain(
"/cryptocurrency/quotes/latest?symbol=BTC&convert=USD"
);
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
});
});
describe("CoinMarketCapProvider — error mapping", () => {
it("surfaces non-2xx responses as HTTP_ERROR", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("nope", {
status: 503,
statusText: "Down",
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["BTC"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
expect(err.status).toBe(503);
expect(err.providerName).toBe("coinmarketcap");
}
});
it("raises PARSE_ERROR when the body is not valid JSON", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("not-json", {
status: 200,
headers: { "content-type": "application/json" },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["BTC"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.PARSE_ERROR
);
}
});
it("raises PARSE_ERROR when the body is JSON null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("null", {
status: 200,
headers: { "content-type": "application/json" },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
});
});
it("raises PARSE_ERROR when the data field is missing", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({ status: { error_code: null } })
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
});
});
it("raises PARSE_ERROR when the data field is JSON null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse({ data: null }));
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
});
});
it("raises PARSE_ERROR when the data field is not an object", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({ data: "not-an-object" })
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
});
});
it("raises UPSTREAM_ERROR when status.error_code is populated", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: {},
status: {
error_code: 1006,
error_message: "Insufficient credits",
},
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["BTC"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
expect(err.upstreamMessage).toBe("Insufficient credits");
expect(err.providerName).toBe("coinmarketcap");
}
});
it("raises UPSTREAM_ERROR over PARSE_ERROR when data is missing AND status has an error_code", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
status: { error_code: 401 },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR without an upstreamMessage when error_message is missing", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: {},
status: { error_code: 500 },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["BTC"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
expect(err.upstreamMessage).toBeUndefined();
}
});
it("treats status.error_code === 0 as success", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
status: { error_code: 0, error_message: null },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
const result = await provider.fetchRates({ targets: ["BTC"] });
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
});
it("raises UNKNOWN_CURRENCY when no requested symbol resolved", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse({ data: {} }));
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["DOESNOTEXIST"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
);
}
});
});
describe("CoinMarketCapProvider — rate filtering", () => {
it("skips entries with missing, non-numeric, NaN, or non-positive prices", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: {
BTC: { quote: { USD: { price: 50000 } } },
ETH: { quote: { USD: { price: "2000" } } }, // string — dropped
SOL: { quote: { USD: { price: 0 } } }, // zero — dropped
XRP: { quote: { USD: { price: -1 } } }, // negative — dropped
ADA: { quote: { USD: { price: Number.NaN } } }, // NaN — dropped
DOT: { quote: { EUR: { price: 7 } } }, // wrong base — dropped
DOGE: { quote: {} }, // missing base — dropped
BNB: undefined, // undefined entry — dropped
USDT: { quote: { USD: { price: 1 } } },
},
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
const result = await provider.fetchRates({
base: "USD",
targets: [
"BTC",
"ETH",
"SOL",
"XRP",
"ADA",
"DOT",
"DOGE",
"BNB",
"USDT",
],
});
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "USDT"]);
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
expect(result.rates.USDT).toBe(1);
});
it("skips entries whose quote field is missing entirely", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: {
BTC: { quote: { USD: { price: 50000 } } },
LTC: {}, // entry exists but no quote at all
},
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
const result = await provider.fetchRates({
base: "USD",
targets: ["BTC", "LTC"],
});
expect(Object.keys(result.rates)).toEqual(["BTC"]);
});
});

View File

@@ -0,0 +1,198 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
type ApiKeyConfig,
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
} from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
const PROVIDER_NAME = "coinmarketcap";
const DEFAULT_BASE_URL = "https://pro-api.coinmarketcap.com/v1";
const DEFAULT_BASE = "USD";
const DEFAULT_TARGETS: readonly string[] = [
"BTC",
"ETH",
"SOL",
"USDT",
"USDC",
"BNB",
"XRP",
"ADA",
"DOGE",
"DOT",
];
interface CoinMarketCapQuote {
readonly price?: unknown;
}
interface CoinMarketCapEntry {
readonly quote?: Record<string, CoinMarketCapQuote | undefined>;
}
interface CoinMarketCapPayload {
readonly data?: Record<string, CoinMarketCapEntry | undefined>;
readonly status?: CoinMarketCapStatus;
}
interface CoinMarketCapStatus {
readonly error_code?: unknown;
readonly error_message?: unknown;
}
/**
* CoinMarketCap crypto-rates provider for `@suaveplan/currency`.
*
* Sibling of `@suaveplan/currency-providers`. Implements `CurrencyProvider`
* by issuing an authenticated `GET /cryptocurrency/quotes/latest` against
* `pro-api.coinmarketcap.com`. The `X-CMC_PRO_API_KEY` header carries
* `config.apiKey`. Identity flags are crypto-only (`supportsCrypto: true`,
* `supportsFiat: false`).
*/
export class CoinMarketCapProvider implements CurrencyProvider {
public readonly name = PROVIDER_NAME;
public readonly supportsCrypto = true;
public readonly supportsFiat = false;
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly fetchImpl: FetchLike | undefined;
public constructor(config: ApiKeyConfig) {
if (config.apiKey.trim() === "") {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.MISSING_CONFIG,
"coinmarketcap: apiKey is required",
{ providerName: PROVIDER_NAME }
);
}
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.fetchImpl = config.fetch;
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const base = options?.base ?? DEFAULT_BASE;
const targets =
options?.targets && options.targets.length > 0
? options.targets
: DEFAULT_TARGETS;
const symbolList = targets.join(",");
const url = `${this.baseUrl}/cryptocurrency/quotes/latest?symbol=${symbolList}&convert=${base}`;
const fetchImpl: FetchLike = this.fetchImpl ?? globalThis.fetch;
const response = await httpRequest({
url,
fetch: fetchImpl,
providerName: PROVIDER_NAME,
init: {
method: "GET",
headers: {
"X-CMC_PRO_API_KEY": this.apiKey,
Accept: "application/json",
},
},
...(options?.signal !== undefined
? { signal: options.signal }
: {}),
});
const payload = await parsePayload(response);
assertNoUpstreamError(payload.status);
const rates = collectRates(payload, base);
if (Object.keys(rates).length === 0) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`coinmarketcap: no rates resolved for base ${base}`,
{ providerName: PROVIDER_NAME }
);
}
return { base, rates, timestamp: Date.now() };
}
}
async function parsePayload(response: Response): Promise<CoinMarketCapPayload> {
let raw: unknown;
try {
raw = await response.json();
} catch (cause) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"coinmarketcap: response body is not valid JSON",
{ providerName: PROVIDER_NAME, cause }
);
}
if (raw === null || typeof raw !== "object") {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"coinmarketcap: response body is not a JSON object",
{ providerName: PROVIDER_NAME }
);
}
const payload = raw as CoinMarketCapPayload;
if (!isValidDataMap(payload.data)) {
// Allow the upstream status check to raise UPSTREAM_ERROR first;
// only raise PARSE_ERROR when there is no usable upstream signal.
if (!hasUpstreamErrorCode(payload.status)) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"coinmarketcap: response missing or malformed data field",
{ providerName: PROVIDER_NAME }
);
}
}
return payload;
}
function isValidDataMap(data: unknown): data is Record<string, unknown> {
return typeof data === "object" && data !== null;
}
function hasUpstreamErrorCode(
status: CoinMarketCapStatus | undefined
): boolean {
const code = status?.error_code;
return typeof code === "number" && code !== 0;
}
function assertNoUpstreamError(status: CoinMarketCapStatus | undefined): void {
if (!hasUpstreamErrorCode(status)) return;
const upstreamMessage =
typeof status?.error_message === "string"
? status.error_message
: undefined;
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
`coinmarketcap: upstream error_code ${String(status?.error_code)}`,
{
providerName: PROVIDER_NAME,
...(upstreamMessage !== undefined ? { upstreamMessage } : {}),
}
);
}
function collectRates(
payload: CoinMarketCapPayload,
base: string
): Record<string, number> {
const rates: Record<string, number> = {};
// By the time we reach here, `parsePayload` + `assertNoUpstreamError`
// have guaranteed `payload.data` is a non-null object — payloads with a
// missing `data` raise either `PARSE_ERROR` (no upstream signal) or
// `UPSTREAM_ERROR` (upstream signalled the failure).
const data = payload.data as Record<string, CoinMarketCapEntry | undefined>;
for (const [symbol, entry] of Object.entries(data)) {
const price = entry?.quote?.[base]?.price;
if (typeof price === "number" && Number.isFinite(price) && price > 0) {
rates[symbol] = 1 / price;
}
}
return rates;
}

View File

@@ -0,0 +1 @@
export { CoinMarketCapProvider } from "./coinmarketcap.js";

View File

@@ -0,0 +1 @@
export { CoinMarketCapProvider } from "./coinmarketcap/coinmarketcap.js";

View File

@@ -0,0 +1,7 @@
// Polyfills for test environment — none required. The package is
// universal and uses only `globalThis.fetch` (overridable via
// `FetchLike` injection), `Object.entries`, `Number.isFinite`, and
// `Date.now`, all available in Node 18+, Bun, browsers, and Web
// Workers. This file exists to give vitest a stable setup-file path
// mirroring the sibling-package layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/currency-provider-coinmarketcap",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/currency",
"@suaveplan/currency-providers",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,68 @@
{
"name": "@suaveplan/currency-provider-cryptocompare",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./cryptocompare/cryptocompare": {
"import": {
"types": "./dist/cryptocompare/cryptocompare.d.ts",
"default": "./dist/cryptocompare/cryptocompare.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/currency": "workspace:^",
"@suaveplan/currency-providers": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

@@ -0,0 +1,316 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import { CryptoCompareProvider } from "./cryptocompare.js";
interface Call {
url: string;
init: RequestInit | undefined;
}
function recordingFetch(
respond: (call: Call) => Response | Promise<Response>
): { fetch: FetchLike; calls: Call[] } {
const calls: Call[] = [];
const fetch: FetchLike = async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
const call: Call = { url, init };
calls.push(call);
return respond(call);
};
return { fetch, calls };
}
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
describe("CryptoCompareProvider", () => {
it("reports stable metadata identifying the provider", {
timeout: 5000,
}, () => {
const provider = new CryptoCompareProvider();
expect(provider.name).toBe("cryptocompare");
expect(provider.supportsCrypto).toBe(true);
expect(provider.supportsFiat).toBe(false);
});
it("inverts pricemulti payload, skips zero prices, and defaults base to USD", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
BTC: { USD: 67000 },
ETH: { USD: 3500 },
FAKE: { USD: 0 },
})
);
const provider = new CryptoCompareProvider({ fetch });
const result = await provider.fetchRates();
expect(result.base).toBe("USD");
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
expect(result.rates.FAKE).toBeUndefined();
expect(typeof result.timestamp).toBe("number");
expect(calls[0]?.url).toContain("/pricemulti?fsyms=");
expect(calls[0]?.url).toContain("&tsyms=USD");
});
it("sends authorization header only when an apiKey is provided", {
timeout: 5000,
}, async () => {
const { fetch: keylessFetch, calls: keylessCalls } = recordingFetch(
() => jsonResponse({ BTC: { USD: 50000 } })
);
await new CryptoCompareProvider({ fetch: keylessFetch }).fetchRates({
base: "USD",
targets: ["BTC"],
});
expect(keylessCalls[0]?.init?.headers).toBeUndefined();
const { fetch: keyedFetch, calls: keyedCalls } = recordingFetch(() =>
jsonResponse({ BTC: { USD: 50000 } })
);
await new CryptoCompareProvider({
fetch: keyedFetch,
apiKey: "secret-key",
}).fetchRates({ base: "USD", targets: ["BTC"] });
const headers = keyedCalls[0]?.init?.headers as
| Record<string, string>
| undefined;
expect(headers?.authorization).toBe("Apikey secret-key");
});
it("treats whitespace-only apiKey as absent and omits the header", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({ BTC: { USD: 50000 } })
);
await new CryptoCompareProvider({
fetch,
apiKey: " ",
}).fetchRates({ base: "USD", targets: ["BTC"] });
expect(calls[0]?.init?.headers).toBeUndefined();
});
it("honours custom baseUrl and explicit targets list", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({ BTC: { EUR: 60000 }, ETH: { EUR: 3000 } })
);
const provider = new CryptoCompareProvider({
fetch,
baseUrl: "https://staging.example.com/data",
});
const result = await provider.fetchRates({
base: "EUR",
targets: ["BTC", "ETH"],
});
expect(calls[0]?.url).toBe(
"https://staging.example.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR"
);
expect(result.base).toBe("EUR");
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "ETH"]);
});
it("falls back to defaults when targets array is empty", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({ BTC: { USD: 50000 } })
);
await new CryptoCompareProvider({ fetch }).fetchRates({
base: "USD",
targets: [],
});
expect(calls[0]?.url).toContain("fsyms=BTC,ETH,SOL,USDT");
});
it("raises HTTP_ERROR on non-2xx response", { timeout: 5000 }, async () => {
const fetch: FetchLike = async () =>
new Response("", {
status: 502,
statusText: "Bad Gateway",
});
const provider = new CryptoCompareProvider({ fetch });
await expect(
provider.fetchRates({ base: "USD" })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.HTTP_ERROR,
providerName: "cryptocompare",
status: 502,
});
});
it("raises PARSE_ERROR when the response body is not valid JSON", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
new Response("<<not json>>", {
status: 200,
headers: { "content-type": "application/json" },
});
const provider = new CryptoCompareProvider({ fetch });
await expect(
provider.fetchRates({ base: "USD" })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
providerName: "cryptocompare",
});
});
it("raises PARSE_ERROR when the parsed payload is not an object", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
new Response("null", {
status: 200,
headers: { "content-type": "application/json" },
});
const provider = new CryptoCompareProvider({ fetch });
const error = await provider
.fetchRates({ base: "USD" })
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.PARSE_ERROR
);
});
it("raises UPSTREAM_ERROR when payload carries Response='Error' with Message", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
jsonResponse({
Response: "Error",
Message: "tsym XYZ is not available",
});
const provider = new CryptoCompareProvider({ fetch });
const error = await provider
.fetchRates({ base: "XYZ" })
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
);
expect((error as CurrencyProviderError).upstreamMessage).toContain(
"tsym XYZ"
);
});
it("raises UPSTREAM_ERROR with default message when Message field is absent", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
jsonResponse({ Response: "Error" });
const provider = new CryptoCompareProvider({ fetch });
const error = await provider
.fetchRates({ base: "USD" })
.catch((cause: unknown) => cause);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
);
expect((error as CurrencyProviderError).upstreamMessage).toBe(
"unknown upstream error"
);
});
it("raises UNKNOWN_CURRENCY when no symbols resolve to a quote", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () => jsonResponse({});
const provider = new CryptoCompareProvider({ fetch });
await expect(
provider.fetchRates({ base: "USD", targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
providerName: "cryptocompare",
});
});
it("raises UNKNOWN_CURRENCY when every quote is non-finite or non-positive", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
jsonResponse({
BTC: { USD: 0 },
ETH: { USD: Number.NaN },
SOL: { USD: -1 },
});
const provider = new CryptoCompareProvider({ fetch });
await expect(
provider.fetchRates({
base: "USD",
targets: ["BTC", "ETH", "SOL"],
})
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
});
});
it("preserves PARSE_ERROR when the response body throws a non-Error value", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () => {
const response = new Response("ignored", {
status: 200,
headers: { "content-type": "application/json" },
});
// Replace `.json()` with a closure that rejects with a non-Error.
Object.defineProperty(response, "json", {
value: async () => {
const failure: unknown = "string-thrown";
throw failure;
},
});
return response;
};
const provider = new CryptoCompareProvider({ fetch });
const error = await provider
.fetchRates({ base: "USD" })
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.PARSE_ERROR
);
expect((error as CurrencyProviderError).cause).toBeUndefined();
});
it("forwards a caller AbortSignal through httpRequest", {
timeout: 5000,
}, async () => {
const seen: { signal: AbortSignal | null | undefined }[] = [];
const fetch: FetchLike = async (_url, init) => {
seen.push({ signal: init?.signal });
return jsonResponse({ BTC: { USD: 50000 } });
};
const controller = new AbortController();
await new CryptoCompareProvider({ fetch }).fetchRates({
base: "USD",
targets: ["BTC"],
signal: controller.signal,
});
expect(seen[0]?.signal).toBe(controller.signal);
});
});

View File

@@ -0,0 +1,168 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
const DEFAULT_URL = "https://min-api.cryptocompare.com/data";
const NAME = "cryptocompare";
const DEFAULT_BASE = "USD";
const DEFAULT_SYMBOLS = [
"BTC",
"ETH",
"SOL",
"USDT",
"USDC",
"BNB",
"XRP",
"ADA",
"DOGE",
"DOT",
"AVAX",
"LINK",
"UNI",
"ATOM",
"LTC",
] as const;
/**
* Configuration for {@link CryptoCompareProvider}. Extends the shared
* `NoKeyConfig` (`baseUrl?` + injectable `fetch?`) with an **optional**
* `apiKey`. CryptoCompare's free tier works fully keyless; supplying a key
* boosts the rate limit and is sent on every request via the
* `authorization: Apikey <key>` header.
*/
export interface CryptoCompareConfig extends NoKeyConfig {
/** Optional CryptoCompare API key. Free tier works keyless. */
readonly apiKey?: string;
}
interface PriceMultiPayload {
readonly [symbol: string]: Readonly<Record<string, number>>;
}
interface ErrorPayload {
readonly Response?: string;
readonly Message?: string;
}
/**
* CryptoCompare crypto-rates provider. Hits the public
* `min-api.cryptocompare.com/data/pricemulti` endpoint and returns the
* inverted price map (`1 / price`) per requested symbol. Crypto-only —
* `supportsCrypto: true`, `supportsFiat: false`.
*/
export class CryptoCompareProvider implements CurrencyProvider {
public readonly name = NAME;
public readonly supportsCrypto = true;
public readonly supportsFiat = false;
private readonly baseUrl: string;
private readonly apiKey: string | undefined;
private readonly fetchImpl: FetchLike;
public constructor(config?: CryptoCompareConfig) {
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
this.apiKey = trimOrUndefined(config?.apiKey);
this.fetchImpl = config?.fetch ?? globalThis.fetch;
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const base = options?.base ?? DEFAULT_BASE;
const symbols =
options?.targets !== undefined && options.targets.length > 0
? options.targets
: DEFAULT_SYMBOLS;
const url = `${this.baseUrl}/pricemulti?fsyms=${symbols.join(",")}&tsyms=${base}`;
const init: RequestInit =
this.apiKey !== undefined
? { headers: { authorization: `Apikey ${this.apiKey}` } }
: {};
const response = await httpRequest({
url,
fetch: this.fetchImpl,
providerName: NAME,
init,
...(options?.signal !== undefined
? { signal: options.signal }
: {}),
});
const payload = await parseJson(response);
assertNotErrorPayload(payload);
const data = payload as PriceMultiPayload;
const rates: Record<string, number> = {};
for (const [symbol, prices] of Object.entries(data)) {
const price = prices?.[base];
if (
typeof price === "number" &&
Number.isFinite(price) &&
price > 0
) {
rates[symbol] = 1 / price;
}
}
if (Object.keys(rates).length === 0) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`${NAME}: no quotes resolved for base ${base} from symbols ${symbols.join(",")}`,
{ providerName: NAME }
);
}
return { base, rates, timestamp: Date.now() };
}
}
function trimOrUndefined(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
async function parseJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch (cause) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
`${NAME}: failed to parse JSON response`,
{
providerName: NAME,
cause: cause instanceof Error ? cause : undefined,
}
);
}
}
function assertNotErrorPayload(payload: unknown): void {
if (payload === null || typeof payload !== "object") {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
`${NAME}: unexpected payload shape (expected object)`,
{ providerName: NAME }
);
}
const candidate = payload as ErrorPayload;
if (candidate.Response === "Error") {
const message = candidate.Message ?? "unknown upstream error";
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
`${NAME}: upstream error — ${message}`,
{ providerName: NAME, upstreamMessage: message }
);
}
}

View File

@@ -0,0 +1,2 @@
export type { CryptoCompareConfig } from "./cryptocompare.js";
export { CryptoCompareProvider } from "./cryptocompare.js";

View File

@@ -0,0 +1,4 @@
export {
type CryptoCompareConfig,
CryptoCompareProvider,
} from "./cryptocompare/cryptocompare.js";

View File

@@ -0,0 +1,7 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `globalThis.fetch` (overridable via FetchLike injection),
// `URLSearchParams`, `Response`, `Object.entries`, `Date.now`, and standard
// `Error` semantics — available in every supported runtime (Node 18+, Bun,
// browsers, Web Workers). This file exists to give vitest a stable setup file
// path mirroring the sibling-package layout.
export { };

Some files were not shown because too many files have changed in this diff Show More