first commit
This commit is contained in:
147
docs/money/finance/cryptocurrency/README.md
Normal file
147
docs/money/finance/cryptocurrency/README.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# @suaveplan/cryptocurrency - Universal cryptocurrency utilities: wallet address validation across 15 chains with EIP-55 checksumming, unit conversion (ETH/BTC/SOL), chain registry with async adapter, EIP-1559 gas estimation, ERC-20 token registry, calldata/selector decoder, transaction hash parsing. Zero browser/Node deps; injectable LoggerLike; typed CryptocurrencyError hierarchy.
|
||||
|
||||
Universal cryptocurrency utilities for wallet address validation, unit conversion, chain registry, gas estimation, ERC-20 token registry, calldata / function-selector decoding, and transaction hash parsing. Zero browser or Node-specific dependencies — runs unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. Not a wallet, not a signing library — just pure utility primitives.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [address](./src/address/validator.md) — Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for c…
|
||||
- [validator](./src/address/validator.md) — Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for c…
|
||||
- [chain-data](./src/chains/chain-data.md) — Holds the canonical `CHAINS` constant: a `Record<ChainId, ChainInfo>` containing one entry per supported mainnet networ…
|
||||
- [chain-registry](./src/chains/chain-registry.md) — Provides the async chain-registry façade for callers that need dynamic chain data — custom mainnet sets, runtime regist…
|
||||
- [registry](./src/chains/registry.md) — Exposes the synchronous fast-path over the built-in `CHAINS` constant. Every helper here is a pure function that reads…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` clas…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` clas…
|
||||
- [gas](./src/gas/gas-estimator.md) — Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values…
|
||||
- [gas-estimator](./src/gas/gas-estimator.md) — Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values…
|
||||
- [token-data](./src/tokens/token-data.md) — Provides the `COMMON_TOKENS` constant: a curated list of eight well-known ERC-20 tokens on Ethereum mainnet — USDT, USD…
|
||||
- [token-registry](./src/tokens/token-registry.md) — Provides the `TokenRegistry` class for adding, retrieving, and searching ERC-20 / token information across chains, plus…
|
||||
- [transaction](./src/transaction/tx-decoder.md) — Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` c…
|
||||
- [tx-decoder](./src/transaction/tx-decoder.md) — Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` c…
|
||||
- [units](./src/units/converter.md) — Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates…
|
||||
- [converter](./src/units/converter.md) — Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/cryptocurrency
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
estimateGas,
|
||||
formatGwei,
|
||||
getChain,
|
||||
getExplorerTxUrl,
|
||||
satoshiToBitcoin,
|
||||
validateAddress,
|
||||
weiToEther,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
// Multi-chain address validation with auto-detection
|
||||
const r = validateAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28");
|
||||
// { valid: true, normalized: "0x...", chains: ["ethereum", "polygon", ...] }
|
||||
|
||||
// Unit conversion (bigint-pure)
|
||||
weiToEther("1000000000000000000"); // "1"
|
||||
satoshiToBitcoin("100000000"); // "1"
|
||||
|
||||
// Chain metadata + explorer URLs
|
||||
const eth = getChain("ethereum");
|
||||
// { id: "ethereum", evmChainId: 1, decimals: 18, ... }
|
||||
const url = getExplorerTxUrl("ethereum", "0xabc...");
|
||||
|
||||
// EIP-1559 gas estimation
|
||||
const estimate = estimateGas({
|
||||
gasLimit: 21000n,
|
||||
baseFeePerGas: 30000000000n,
|
||||
maxPriorityFeePerGas: 2000000000n,
|
||||
});
|
||||
formatGwei(estimate.gasPrice); // "32"
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Address validation across 15 chains** (Bitcoin, Ethereum, Solana, Polygon, Arbitrum, Optimism, Avalanche, BSC, Base, TRON, Litecoin, Dogecoin, Cardano, Polkadot, Cosmos) via `validateAddress` / `validateAddressForChain`.
|
||||
- **Transaction hash classification** (EVM / UTXO / Solana) via `validateTxHash` and `decodeTxHash`.
|
||||
- **EIP-55 checksumming** for EVM addresses (simplified positional algorithm preserved from upstream; full keccak256 deferred to a follow-up change).
|
||||
- **Static chain registry** with `getChain` / `getAllChainIds` / `getEvmChains` / `getChainByEvmId` / explorer URL helpers.
|
||||
- **Async chain registry** (`ChainRegistry` + `MemoryChainRegistry`) supporting custom adapters and typed `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })`.
|
||||
- **Bigint-pure unit conversion** across ETH / BTC / SOL (`convertUnits`, `weiToEther`, `etherToWei`, `gweiToEther`, `satoshiToBitcoin`, `bitcoinToSatoshi`, `lamportToSol`, `getUnits`).
|
||||
- **EIP-1559 gas estimation** (`estimateGas`, `calculateMaxFeePerGas`, `calculateEffectiveGasPrice`, `formatGwei`, `estimateTxCostEth`).
|
||||
- **ERC-20 token registry** (`TokenRegistry`, `COMMON_TOKENS`, `formatTokenBalance`) — case-insensitive address lookups, substring search by symbol / name.
|
||||
- **Calldata + function-selector decoder** (`KNOWN_SELECTORS`, `lookupSelector`, `extractSelector`, `decodeCalldata`).
|
||||
- **Typed `CryptocurrencyError` hierarchy** with `CRYPTOCURRENCY.*` code registry and optional `chainId` / `currency` / `unit` discriminants.
|
||||
- **Optional `LoggerLike` injection** on `ChainRegistry` (no-op default) emitting `chain.registered` / `chain.lookup.miss` events.
|
||||
- **Tree-shakeable subpath exports** — import only what you use.
|
||||
- **Universal tier** — no `node:*`, no `bun:*`, no `fs` / `path` / `crypto` imports.
|
||||
|
||||
## Modules
|
||||
|
||||
Co-located docs for every implementation file:
|
||||
|
||||
- [`src/address/validator.md`](./src/address/validator.md) — `validateAddress`, `validateAddressForChain`, `validateTxHash`.
|
||||
- [`src/chains/chain-data.md`](./src/chains/chain-data.md) — `CHAINS` constant.
|
||||
- [`src/chains/chain-registry.md`](./src/chains/chain-registry.md) — `ChainRegistry`, `MemoryChainRegistry`, `LoggerLike` integration.
|
||||
- [`src/chains/registry.md`](./src/chains/registry.md) — synchronous getters.
|
||||
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `CRYPTOCURRENCY_CODES`, `CryptocurrencyError`.
|
||||
- [`src/gas/gas-estimator.md`](./src/gas/gas-estimator.md) — EIP-1559 gas helpers.
|
||||
- [`src/tokens/token-data.md`](./src/tokens/token-data.md) — `COMMON_TOKENS`.
|
||||
- [`src/tokens/token-registry.md`](./src/tokens/token-registry.md) — `TokenRegistry`, `formatTokenBalance`.
|
||||
- [`src/transaction/tx-decoder.md`](./src/transaction/tx-decoder.md) — calldata / selector / tx-hash decoder.
|
||||
- [`src/types/crypto.types.md`](./src/types/crypto.types.md) — public type contract.
|
||||
- [`src/units/converter.md`](./src/units/converter.md) — generic + convenience unit conversion.
|
||||
|
||||
## Subpath exports
|
||||
|
||||
Every public module ships its own subpath so consumers can tree-shake:
|
||||
|
||||
```ts
|
||||
import { validateAddress } from "@suaveplan/cryptocurrency/address/validator";
|
||||
import { CHAINS } from "@suaveplan/cryptocurrency/chains/chain-data";
|
||||
import { ChainRegistry } from "@suaveplan/cryptocurrency/chains/chain-registry";
|
||||
import { getChain } from "@suaveplan/cryptocurrency/chains/registry";
|
||||
import { CRYPTOCURRENCY_CODES } from "@suaveplan/cryptocurrency/error-codes/error-codes";
|
||||
import { estimateGas } from "@suaveplan/cryptocurrency/gas/gas-estimator";
|
||||
import { COMMON_TOKENS } from "@suaveplan/cryptocurrency/tokens/token-data";
|
||||
import { TokenRegistry } from "@suaveplan/cryptocurrency/tokens/token-registry";
|
||||
import { decodeCalldata } from "@suaveplan/cryptocurrency/transaction/tx-decoder";
|
||||
import type { ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
|
||||
import { convertUnits } from "@suaveplan/cryptocurrency/units/converter";
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
```ts
|
||||
import {
|
||||
CRYPTOCURRENCY_CODES,
|
||||
CryptocurrencyError,
|
||||
convertUnits,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
try {
|
||||
convertUnits("1", "wei", "ether", "XYZ");
|
||||
} catch (err) {
|
||||
if (err instanceof CryptocurrencyError) {
|
||||
if (err.code === CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY) {
|
||||
console.error(`unsupported currency: ${err.currency}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What this package is not
|
||||
|
||||
- **Not a wallet** — no key management, no transaction signing.
|
||||
- **Not an RPC client** — no on-chain calls; the `ChainRegistry` adapter contract lets you wire your own RPC layer.
|
||||
- **Not an ABI decoder** — `decodeCalldata` only recognises 4-byte selectors. Full ABI decoding (dynamic types, tuples) lands in a follow-up change.
|
||||
- **Not an ENS resolver** — name-resolution lands in a separate package.
|
||||
- **Not RFC-correct EIP-55** — uses a simplified positional checksum preserved from upstream; full keccak256 lands in a follow-up change.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
84
docs/money/finance/cryptocurrency/src/address/validator.md
Normal file
84
docs/money/finance/cryptocurrency/src/address/validator.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# validator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for chain auto-detection from an address string, `validateAddressForChain` for chain-locked validation, and `validateTxHash` for transaction-hash classification. All three return structured `AddressValidation` / `TxHashValidation` records rather than throwing, so callers can route on success / failure without try-catch ceremony. The validators recognise 11 distinct address shapes across 15 chains and three transaction-hash shapes across the EVM / UTXO / Solana families.
|
||||
|
||||
This module is the broadest consumer-facing entry point in the package — every wallet, payment, or explorer integration starts here. The structured-return contract (no throws) was inherited from upstream and preserved so existing consumers can migrate without changing call sites.
|
||||
|
||||
## Features
|
||||
|
||||
- 15-chain address detection (`validateAddress`) covering EVM hex, Bitcoin legacy + Segwit, Litecoin legacy + Segwit, Solana base58, TRON base58, Dogecoin base58, Cardano bech32, Cosmos bech32, Polkadot ss58.
|
||||
- Chain-locked validation (`validateAddressForChain`) narrowing the result to a single id on success.
|
||||
- Transaction hash classification (`validateTxHash`) returning the set of chains a hash could belong to.
|
||||
- Positional EIP-55 checksumming on EVM `normalized` output (preserved bit-for-bit from upstream — see Implementation Notes for the keccak256 deferral).
|
||||
- Leading/trailing whitespace trimmed on every input.
|
||||
- Structured failure returns (`{ valid: false, chains: [], error: "..." }`) — no throws.
|
||||
- Universal — no `node:*` / `bun:*` / `fs` / `path` / `crypto` imports.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateAddress, validateTxHash } from "@suaveplan/cryptocurrency";
|
||||
|
||||
const r1 = validateAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28");
|
||||
// { valid: true, chains: ["ethereum", "polygon", ...], normalized: "0x..." }
|
||||
|
||||
const r2 = validateTxHash(`0x${"a".repeat(64)}`);
|
||||
// { valid: true, chains: ["ethereum", "polygon", ...] }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CRYPTOCURRENCY_CODES,
|
||||
CryptocurrencyError,
|
||||
type ChainId,
|
||||
validateAddressForChain,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
function requireAddressForChain(address: string, chainId: ChainId): string {
|
||||
const result = validateAddressForChain(address, chainId);
|
||||
if (!result.valid) {
|
||||
throw new CryptocurrencyError(
|
||||
CRYPTOCURRENCY_CODES.INVALID_ADDRESS,
|
||||
result.error ?? `Invalid address for ${chainId}`,
|
||||
{ chainId }
|
||||
);
|
||||
}
|
||||
return result.normalized ?? address;
|
||||
}
|
||||
|
||||
const normalized = requireAddressForChain(userInput, "ethereum");
|
||||
```
|
||||
|
||||
This pattern lets application-tier consumers turn the validators' structured returns into typed throws when their architecture prefers exceptions. The exposed `CRYPTOCURRENCY.INVALID_ADDRESS` code makes the failure mode programmatically dispatchable.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Notes |
|
||||
|---|---|---|
|
||||
| `validateAddress` | `(address: string) => AddressValidation` | auto-detects chains; populates `normalized` (positional-EIP-55 checksum) for EVM matches |
|
||||
| `validateAddressForChain` | `(address: string, chainId: ChainId) => AddressValidation` | narrows to a single chain on success; propagates `validateAddress`'s failure verbatim |
|
||||
| `validateTxHash` | `(hash: string) => TxHashValidation` | classifies EVM / UTXO / Solana hash shapes |
|
||||
|
||||
### Failure messages
|
||||
|
||||
- Empty / null / non-string `address` → `"Address must be a non-empty string"`
|
||||
- No pattern matches → `"Address does not match any known format"`
|
||||
- Address validates but not for the requested chain → `` `Address is not valid for ${chainId}` ``
|
||||
- Empty / null / non-string `hash` → `"Hash must be a non-empty string"`
|
||||
- No hash pattern matches → `"Hash does not match any known format"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `ADDRESS_PATTERNS` table is the single source of truth for address detection. Adding a chain means adding one entry to the table and one entry to the `ChainId` union — no other code changes required.
|
||||
- The EIP-55 checksum implementation is **NOT** RFC-correct. It is a simplified positional algorithm (`even-index alpha → uppercase, odd-index alpha → lowercase`) preserved bit-for-bit from upstream so consumers depending on its output keep working. The audit (`enterprise/cryptocurrency.md` L57-58) flags this gap; the swap to keccak256 lands in a follow-up change gated on `@suaveplan/crypto-hash` being wired in.
|
||||
- The transaction-hash classifier returns plain arrays (not the readonly tuples it stores internally) so callers can freely mutate the result without affecting subsequent calls.
|
||||
- All regex patterns are anchored (`^...$`) to prevent partial-string matches.
|
||||
- The validators never throw — the only `throw` pattern in the entire module would be unreachable, so the public contract is "data in, structured data out, no exceptions."
|
||||
62
docs/money/finance/cryptocurrency/src/chains/chain-data.md
Normal file
62
docs/money/finance/cryptocurrency/src/chains/chain-data.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# chain-data
|
||||
|
||||
## Purpose
|
||||
|
||||
Holds the canonical `CHAINS` constant: a `Record<ChainId, ChainInfo>` containing one entry per supported mainnet network. This is the single source of truth for every chain-related lookup in the package — the synchronous helpers in `registry.ts`, the default seed for `MemoryChainRegistry`, and the `validateAddress` chain-detection table all derive their data from here.
|
||||
|
||||
The constant is shipped as a plain object literal rather than being constructed at runtime, so consumers' bundlers can dead-code-eliminate the entries they don't use when importing via the `./chains/chain-data` subpath export.
|
||||
|
||||
## Features
|
||||
|
||||
- Exactly 15 entries: bitcoin, ethereum, solana, polygon, arbitrum, optimism, avalanche, bsc, base, tron, litecoin, dogecoin, cardano, polkadot, cosmos.
|
||||
- EVM chains carry the canonical mainnet `evmChainId` (Ethereum 1, BSC 56, Polygon 137, Optimism 10, Base 8453, Arbitrum 42161, Avalanche 43114).
|
||||
- Non-EVM chains omit `evmChainId`.
|
||||
- Every entry declares `id`, `name`, `nativeSymbol`, `addressType`, `explorerUrl`, `decimals`, `testnet: false`.
|
||||
- Explorer URLs are base templates — the synchronous helpers in `registry.ts` and the async `ChainRegistry` append `/address/<addr>` or `/tx/<hash>` to produce concrete URLs.
|
||||
- Pure data export — no runtime initialisation, no side effects.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CHAINS } from "@suaveplan/cryptocurrency";
|
||||
|
||||
const eth = CHAINS.ethereum;
|
||||
// { id: "ethereum", name: "Ethereum", evmChainId: 1, decimals: 18, ... }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CHAINS } from "@suaveplan/cryptocurrency";
|
||||
import type { ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
|
||||
|
||||
function evmChainsByDecimals(): Map<number, ChainInfo[]> {
|
||||
const out = new Map<number, ChainInfo[]>();
|
||||
for (const chain of Object.values(CHAINS)) {
|
||||
if (chain.evmChainId === undefined) continue;
|
||||
const bucket = out.get(chain.decimals) ?? [];
|
||||
bucket.push(chain);
|
||||
out.set(chain.decimals, bucket);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const grouped = evmChainsByDecimals();
|
||||
// Map { 18 => [ethereum, polygon, arbitrum, optimism, avalanche, bsc, base] }
|
||||
```
|
||||
|
||||
Iterating `Object.values(CHAINS)` is the canonical way to enumerate the registry; the `ChainId` union and the `Record` constraint guarantee TypeScript sees every key as a literal string.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Notes |
|
||||
|---|---|---|
|
||||
| `CHAINS` | `Record<ChainId, ChainInfo>` | 15-entry mainnet registry |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The constant is `const` but **not** `as const` — the entries are mutable to allow consumers to spread-merge their own overrides (`{ ...CHAINS, custom: { ... } }`). If callers want a frozen view they can wrap with `Object.freeze` themselves.
|
||||
- The object literal layout is alphabetised by mnemonic within each group (Bitcoin / EVM / Solana / others) for readability; the iteration order matters for `validateAddress`'s chain-detection table because the EVM regex matches a superset of single-EVM-chain addresses.
|
||||
- Adding a new mainnet chain requires four edits: extend the `ChainId` union in `types/crypto.types.ts`, add a `CHAINS` entry here, add a row to the `ADDRESS_PATTERNS` table in `address/validator.ts`, and extend the chain-data test's `EXPECTED_IDS` list.
|
||||
- Testnet support is deliberately scoped out of this migration — testnets land in `add-cryptocurrency-testnets` per `enterprise/cryptocurrency.md` §4.
|
||||
- The `explorerUrl` values are base hosts only; the trailing path segments (`/address/...`, `/tx/...`) are added by `registry.ts` so they can be swapped without rewriting the table.
|
||||
126
docs/money/finance/cryptocurrency/src/chains/chain-registry.md
Normal file
126
docs/money/finance/cryptocurrency/src/chains/chain-registry.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# chain-registry
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the async chain-registry façade for callers that need dynamic chain data — custom mainnet sets, runtime registrations, DB-backed or HTTP-backed adapters. `MemoryChainRegistry` is the default in-memory implementation of the `ChainRegistryAdapter` contract; `ChainRegistry` is a thin façade over an injected adapter that adds an `LoggerLike` integration and raises typed `CryptocurrencyError` for missing-chain explorer-URL lookups.
|
||||
|
||||
Use this module when the synchronous `registry.ts` helpers (which read from the built-in `CHAINS` constant) are insufficient — for example, when callers want to register their own chain mid-process, when chain data is loaded from a backend, or when typed-error dispatch is preferred over `undefined` checks.
|
||||
|
||||
## Features
|
||||
|
||||
- `MemoryChainRegistry implements ChainRegistryAdapter` — `Map`-backed in-memory storage; preloads built-in `CHAINS` or accepts a custom `ChainInfo[]` seed.
|
||||
- `ChainRegistry` — façade over any `ChainRegistryAdapter`; defaults to a fresh `MemoryChainRegistry` when no adapter is provided.
|
||||
- Optional `LoggerLike` injection on `ChainRegistry` constructor — no-op default.
|
||||
- Operational events: `chain.registered` (info) on `registerChain`; `chain.lookup.miss` (debug) when an explorer URL is requested for an unknown chain.
|
||||
- `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })` raised by `getExplorerAddressUrl` / `getExplorerTxUrl` for unknown chains — typed dispatch instead of `undefined`.
|
||||
- Universal — no `node:*` / `bun:*` / `fs` / `path` / `crypto` imports.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ChainRegistry } from "@suaveplan/cryptocurrency";
|
||||
|
||||
const registry = new ChainRegistry();
|
||||
const eth = await registry.getChain("ethereum");
|
||||
const url = await registry.getExplorerTxUrl("ethereum", "0xabc...");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChainRegistry,
|
||||
type ChainInfo,
|
||||
type ChainRegistryAdapter,
|
||||
MemoryChainRegistry,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
// Wrap an HTTP-backed source as a ChainRegistryAdapter
|
||||
class HttpChainAdapter implements ChainRegistryAdapter {
|
||||
public constructor(private readonly baseUrl: string) {}
|
||||
|
||||
public async getChain(id: ChainInfo["id"]): Promise<ChainInfo | undefined> {
|
||||
const res = await fetch(`${this.baseUrl}/chains/${id}`);
|
||||
return res.ok ? (res.json() as Promise<ChainInfo>) : undefined;
|
||||
}
|
||||
|
||||
public async getAllChains(): Promise<ChainInfo[]> {
|
||||
const res = await fetch(`${this.baseUrl}/chains`);
|
||||
return res.json() as Promise<ChainInfo[]>;
|
||||
}
|
||||
|
||||
public async getChainByEvmId(id: number): Promise<ChainInfo | undefined> {
|
||||
const res = await fetch(`${this.baseUrl}/chains/evm/${id}`);
|
||||
return res.ok ? (res.json() as Promise<ChainInfo>) : undefined;
|
||||
}
|
||||
|
||||
public async registerChain(): Promise<void> {
|
||||
throw new Error("read-only adapter");
|
||||
}
|
||||
}
|
||||
|
||||
const logger: LoggerLike = {
|
||||
debug: (m, c) => console.log("[debug]", m, c),
|
||||
info: (m, c) => console.log("[info]", m, c),
|
||||
warn: (m, c) => console.warn("[warn]", m, c),
|
||||
error: (m, c) => console.error("[error]", m, c),
|
||||
};
|
||||
|
||||
const remote = new ChainRegistry(
|
||||
new HttpChainAdapter("https://chains.example.com"),
|
||||
logger
|
||||
);
|
||||
|
||||
try {
|
||||
await remote.getExplorerTxUrl("unsupported" as ChainInfo["id"], "0xabc");
|
||||
} catch (err) {
|
||||
// CryptocurrencyError { code: "CRYPTOCURRENCY.UNKNOWN_CHAIN", chainId: "unsupported" }
|
||||
}
|
||||
|
||||
// MemoryChainRegistry also accepts a custom seed for tests
|
||||
const empty = new MemoryChainRegistry([]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MemoryChainRegistry`
|
||||
|
||||
| Method | Signature | Notes |
|
||||
|---|---|---|
|
||||
| constructor | `(initialChains?: ChainInfo[])` | preloads built-in `CHAINS` when no seed is provided |
|
||||
| `getChain` | `(id: ChainId) => Promise<ChainInfo | undefined>` | |
|
||||
| `getAllChains` | `() => Promise<ChainInfo[]>` | returns a snapshot array |
|
||||
| `getChainByEvmId` | `(evmChainId: number) => Promise<ChainInfo | undefined>` | |
|
||||
| `registerChain` | `(chain: ChainInfo) => Promise<void>` | upsert by `chain.id` |
|
||||
|
||||
### `ChainRegistry`
|
||||
|
||||
| Method | Signature | Notes |
|
||||
|---|---|---|
|
||||
| constructor | `(adapter?: ChainRegistryAdapter, logger?: LoggerLike)` | defaults to fresh `MemoryChainRegistry` + no-op logger |
|
||||
| `getChain` | `(id: ChainId) => Promise<ChainInfo | undefined>` | forwards to adapter |
|
||||
| `getAllChains` | `() => Promise<ChainInfo[]>` | forwards to adapter |
|
||||
| `getChainByEvmId` | `(evmChainId: number) => Promise<ChainInfo | undefined>` | forwards to adapter |
|
||||
| `registerChain` | `(chain: ChainInfo) => Promise<void>` | forwards to adapter; logs `chain.registered` (info) |
|
||||
| `getEvmChains` | `() => Promise<ChainInfo[]>` | filter of `getAllChains()` |
|
||||
| `getExplorerAddressUrl` | `(chainId: ChainId, address: string) => Promise<string>` | raises `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })` on missing chains; logs `chain.lookup.miss` (debug) |
|
||||
| `getExplorerTxUrl` | `(chainId: ChainId, txHash: string) => Promise<string>` | same error/logger behaviour as above |
|
||||
|
||||
### Error contract
|
||||
|
||||
- `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })` raised by both explorer URL methods when the adapter returns `undefined`.
|
||||
|
||||
### Logger events
|
||||
|
||||
- `chain.registered` — info — `{ chainId, evmChainId }` — emitted on every successful `registerChain` call.
|
||||
- `chain.lookup.miss` — debug — `{ chainId }` — emitted before raising `UNKNOWN_CHAIN`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `MemoryChainRegistry` stores entries in a `Map<string, ChainInfo>` keyed by `chain.id`. The key type is `string` (not `ChainId`) so callers can register chains whose ids are not in the built-in `ChainId` union — useful for sidechains or testnets registered at runtime. Reading via `getChain(id)` still requires a `ChainId` because that's the contract; consumers wanting to look up runtime-registered ids should cast (`"sepolia" as ChainId`) and accept the typing risk.
|
||||
- `ChainRegistry`'s constructor accepts the adapter and logger as positional optional parameters in that order. This mirrors the pattern used by sibling packages (`SeedRunner(registry, store, logger?)`) so reviewers can spot the wiring quickly.
|
||||
- The default `NOOP_LOGGER` has empty method bodies, not `() => undefined`, so the V8 inliner can eliminate the call entirely under `-O3` profiles.
|
||||
- `getExplorerAddressUrl` and `getExplorerTxUrl` are intentionally duplicated rather than refactored to a shared helper — the duplication is two lines, the call sites are the entire reason the typed error exists, and extracting would obscure the error's discriminant context.
|
||||
- The `chain.lookup.miss` debug event is emitted **before** the throw so structured-log consumers can correlate the miss with the error. The throw is unconditional; consumers wanting silent-undefined behaviour should use the synchronous `registry.ts` helpers instead, which return `undefined` from `getChain` directly.
|
||||
- Tier purity: only `@suaveplan/types` (types-only) and `@suaveplan/error` (universal) imports — no `node:*`, no `bun:*`.
|
||||
71
docs/money/finance/cryptocurrency/src/chains/registry.md
Normal file
71
docs/money/finance/cryptocurrency/src/chains/registry.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# registry (synchronous chain getters)
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes the synchronous fast-path over the built-in `CHAINS` constant. Every helper here is a pure function that reads from `CHAINS` and returns either the matching `ChainInfo` or a derived value (filtered list, concatenated explorer URL). Use this module when you only need read access to the static mainnet registry — it has zero allocation overhead beyond the returned arrays.
|
||||
|
||||
For dynamic registries (custom chain sets, DB-backed lookups, runtime registrations) use `ChainRegistry` from `./chain-registry` instead.
|
||||
|
||||
## Features
|
||||
|
||||
- `getChain(id)` — direct lookup against the built-in `CHAINS` constant.
|
||||
- `getAllChainIds()` — returns all 15 ids in registry order.
|
||||
- `getEvmChains()` — filtered view of EVM-compatible chains.
|
||||
- `getChainByEvmId(evmChainId)` — reverse lookup by canonical EVM chain id.
|
||||
- `getExplorerAddressUrl(chainId, address)` — `${explorerUrl}/address/${address}`.
|
||||
- `getExplorerTxUrl(chainId, txHash)` — `${explorerUrl}/tx/${txHash}`.
|
||||
- Pure functions — no internal state, no side effects, safe to call from any context.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getChain, getExplorerTxUrl } from "@suaveplan/cryptocurrency";
|
||||
|
||||
const eth = getChain("ethereum");
|
||||
const url = getExplorerTxUrl("ethereum", "0xabc...");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
getChainByEvmId,
|
||||
getEvmChains,
|
||||
getExplorerAddressUrl,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
function buildEvmDashboard(evmChainId: number, address: string) {
|
||||
const chain = getChainByEvmId(evmChainId);
|
||||
if (!chain) return null;
|
||||
return {
|
||||
chain: chain.name,
|
||||
symbol: chain.nativeSymbol,
|
||||
explorer: getExplorerAddressUrl(chain.id, address),
|
||||
siblings: getEvmChains()
|
||||
.filter((c) => c.id !== chain.id)
|
||||
.map((c) => c.name),
|
||||
};
|
||||
}
|
||||
|
||||
const dashboard = buildEvmDashboard(137, "0xabc...");
|
||||
// { chain: "Polygon", symbol: "POL", explorer: "...", siblings: ["Ethereum", "Arbitrum One", ...] }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Notes |
|
||||
|---|---|---|
|
||||
| `getChain` | `(id: ChainId) => ChainInfo` | direct, total — no `undefined` because `ChainId` is a closed union over the built-in keys |
|
||||
| `getAllChainIds` | `() => ChainId[]` | order is the `CHAINS` literal's declaration order |
|
||||
| `getEvmChains` | `() => ChainInfo[]` | filtered by `evmChainId !== undefined` |
|
||||
| `getChainByEvmId` | `(evmChainId: number) => ChainInfo | undefined` | reverse lookup; returns `undefined` for unknown ids |
|
||||
| `getExplorerAddressUrl` | `(chainId: ChainId, address: string) => string` | template + `/address/<addr>` |
|
||||
| `getExplorerTxUrl` | `(chainId: ChainId, txHash: string) => string` | template + `/tx/<hash>` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `getChain` is total because `ChainId` is a closed union over the keys of `CHAINS`. Passing a string asserted as `ChainId` (`"unknown" as ChainId`) bypasses the type check and yields `undefined` at runtime — callers responsible for the cast are responsible for the consequence.
|
||||
- `getAllChainIds` returns a fresh array on each call so callers can mutate it (push/sort/filter) without affecting the registry.
|
||||
- `getEvmChains` allocates a fresh array each call — for hot paths, cache the result.
|
||||
- The explorer URL helpers do **not** URL-encode the address or tx-hash argument. Hex addresses and hashes are safe in URLs by definition; if the caller passes a value containing reserved characters, the resulting URL will be malformed. This is intentional — encoding would be lossy for hex inputs (e.g. converting `0x` to `0x` is a no-op but obscures intent).
|
||||
- For dynamic chain registries (custom mainnet sets, DB-backed adapters, runtime additions), use `ChainRegistry` and `MemoryChainRegistry` from `./chain-registry` — they implement the async `ChainRegistryAdapter` contract and raise `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })` on missing chains.
|
||||
@@ -0,0 +1,94 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` class that wraps every failure path. The registry uses `defineCodes("CRYPTOCURRENCY", [...])` to produce a frozen, literal-typed map (`{ readonly UNKNOWN_CHAIN: "CRYPTOCURRENCY.UNKNOWN_CHAIN", ... }`) so consumers can dispatch programmatically on `err.code === CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN` with full TypeScript narrowing.
|
||||
|
||||
Centralising the registry guarantees the error contract is single-sourced — every throw site in the package picks from the same five-code set, and the exposed `CryptocurrencyErrorCode` union travels with consumers' code so a future code addition triggers an exhaustiveness check at every switch site.
|
||||
|
||||
## Features
|
||||
|
||||
- Five codes covering every distinct failure mode in the migrated surface: `UNKNOWN_CHAIN`, `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT`, `INVALID_ADDRESS`, `INVALID_HASH`.
|
||||
- `CryptocurrencyError extends SuaveplanError<CryptocurrencyErrorCode>` with optional `chainId` / `currency` / `unit` discriminants for diagnostics.
|
||||
- `CryptocurrencyErrorCode` literal union exported for consumer narrowing.
|
||||
- `CryptocurrencyErrorOptions` extends `SuaveplanErrorOptions` so `cause`, `context`, `traceId`, and `clock` are all available.
|
||||
- The registry is `Object.freeze`d so consumers cannot mutate the code map.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CRYPTOCURRENCY_CODES,
|
||||
CryptocurrencyError,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
throw new CryptocurrencyError(
|
||||
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN,
|
||||
"Unknown chain: ethereum",
|
||||
{ chainId: "ethereum" }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CRYPTOCURRENCY_CODES,
|
||||
CryptocurrencyError,
|
||||
type CryptocurrencyErrorCode,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
function describeFailure(err: unknown): string {
|
||||
if (!(err instanceof CryptocurrencyError)) return "non-crypto error";
|
||||
const code: CryptocurrencyErrorCode = err.code;
|
||||
switch (code) {
|
||||
case CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN:
|
||||
return `unknown chain: ${err.chainId ?? "<unset>"}`;
|
||||
case CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY:
|
||||
return `unknown currency: ${err.currency ?? "<unset>"}`;
|
||||
case CRYPTOCURRENCY_CODES.UNKNOWN_UNIT:
|
||||
return `unknown unit '${err.unit}' for ${err.currency}`;
|
||||
case CRYPTOCURRENCY_CODES.INVALID_ADDRESS:
|
||||
return `invalid address${err.chainId ? ` for ${err.chainId}` : ""}`;
|
||||
case CRYPTOCURRENCY_CODES.INVALID_HASH:
|
||||
return `invalid hash${err.chainId ? ` for ${err.chainId}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// some package call
|
||||
} catch (err) {
|
||||
console.error(describeFailure(err));
|
||||
}
|
||||
```
|
||||
|
||||
The exhaustive switch above will fail to compile if a new code is added without a matching arm — that is the point of exporting `CryptocurrencyErrorCode`.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `CRYPTOCURRENCY_CODES` | const | frozen `{ readonly <REASON>: "CRYPTOCURRENCY.<REASON>" }` map |
|
||||
| `CryptocurrencyErrorCode` | type | union of every registered code's string literal |
|
||||
| `CryptocurrencyError` | class | `extends SuaveplanError<CryptocurrencyErrorCode>`; constructor `(code, message, options?)` |
|
||||
| `CryptocurrencyErrorOptions` | interface | `extends SuaveplanErrorOptions`; adds optional `chainId`, `currency`, `unit` |
|
||||
|
||||
### `CryptocurrencyError` discriminants
|
||||
|
||||
- `chainId?: ChainId` — set on `UNKNOWN_CHAIN`, `INVALID_ADDRESS`, `INVALID_HASH` raises.
|
||||
- `currency?: string` — set on `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT` raises.
|
||||
- `unit?: string` — set on `UNKNOWN_UNIT` raises.
|
||||
|
||||
All discriminants are read-only and `undefined` when not supplied — consumers should narrow with `if (err.chainId)` rather than asserting non-null.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `INVALID_ADDRESS` and `INVALID_HASH` are exposed in the registry even though the package's own validators return structured `AddressValidation` / `TxHashValidation` records rather than throwing. The codes are there for application-tier consumers that want to wrap a failed validation in a typed throw. They are exercised by direct construction in `error-codes.test.ts`, satisfying the conformance rule (§13 step 4 in `AGENTS.md`) that every registered code must be reachable from a call site somewhere in `src/`.
|
||||
- `defineCodes` produces a `Object.freeze`d record — mutating `CRYPTOCURRENCY_CODES` throws in strict mode. Consumers must rely on the literal types, not runtime extension.
|
||||
- `CryptocurrencyError` extends `SuaveplanError` (rule §63 of `CLAUDE.md`) so every error in the package participates in cause-chain traversal, OTel span events, redaction, and i18n message resolution.
|
||||
- The error's `name` field is automatically set to `"CryptocurrencyError"` by `SuaveplanError`'s use of `new.target.name`.
|
||||
- The optional discriminants are assigned via `if (options.X !== undefined)` so the field stays `undefined` (and serialises away from JSON) when not provided.
|
||||
119
docs/money/finance/cryptocurrency/src/gas/gas-estimator.md
Normal file
119
docs/money/finance/cryptocurrency/src/gas/gas-estimator.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# gas-estimator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values so callers never lose precision at the JavaScript `number` boundary — gas costs in wei routinely exceed `Number.MAX_SAFE_INTEGER` (2^53 - 1) so passing them as `number` is a bug waiting to happen. The module covers the four canonical operations: estimating a full `GasEstimate` from a single set of parameters, computing the EIP-1559 ceiling (`2 * baseFee + priority`), computing the effective gas price (`min(maxFee, baseFee + priority)`), and formatting wei values as human-readable gwei or ETH decimal strings.
|
||||
|
||||
This module is consumed by wallet UIs, transaction-builder libraries, and analytics dashboards. The bigint-everywhere contract is the headline guarantee.
|
||||
|
||||
## Features
|
||||
|
||||
- `estimateGas(params)` — full EIP-1559 estimator returning `GasEstimate` (gasLimit, gasPrice, maxFeePerGas, maxPriorityFeePerGas, totalCost). Honours an optional `legacyGasPrice` override for non-EIP-1559 transactions.
|
||||
- `calculateMaxFeePerGas(baseFee, priorityFee)` — EIP-1559 ceiling formula.
|
||||
- `calculateEffectiveGasPrice(baseFee, maxFee, priorityFee)` — `min(maxFee, baseFee + priorityFee)`.
|
||||
- `formatGwei(weiValue)` — sign-preserving decimal gwei string with trailing-zero trim.
|
||||
- `estimateTxCostEth(gasLimit, gasPriceWei)` — decimal ETH string (18-decimal contraction with trim).
|
||||
- 100% `bigint` arithmetic — no `Number` conversion anywhere on the hot path.
|
||||
- Universal — no `node:*` / `bun:*` / `fs` / `crypto` imports.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { estimateGas, formatGwei } from "@suaveplan/cryptocurrency";
|
||||
|
||||
const estimate = estimateGas({
|
||||
gasLimit: 21000n,
|
||||
baseFeePerGas: 30000000000n, // 30 gwei
|
||||
maxPriorityFeePerGas: 2000000000n, // 2 gwei
|
||||
});
|
||||
|
||||
console.log(formatGwei(estimate.gasPrice)); // "32"
|
||||
console.log(estimate.totalCost); // 672000000000000n wei = 0.000672 ETH
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
calculateEffectiveGasPrice,
|
||||
calculateMaxFeePerGas,
|
||||
estimateGas,
|
||||
estimateTxCostEth,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
interface BlockHeader {
|
||||
baseFeePerGas: bigint;
|
||||
}
|
||||
|
||||
interface UserPreferences {
|
||||
priorityFee: bigint;
|
||||
gasLimit: bigint;
|
||||
}
|
||||
|
||||
function quoteUserTransaction(header: BlockHeader, prefs: UserPreferences) {
|
||||
// Cap our max-fee at 2x base + priority so we don't get gouged on
|
||||
// sudden base-fee spikes
|
||||
const maxFee = calculateMaxFeePerGas(header.baseFeePerGas, prefs.priorityFee);
|
||||
|
||||
const effective = calculateEffectiveGasPrice(
|
||||
header.baseFeePerGas,
|
||||
maxFee,
|
||||
prefs.priorityFee
|
||||
);
|
||||
|
||||
const estimate = estimateGas({
|
||||
gasLimit: prefs.gasLimit,
|
||||
baseFeePerGas: header.baseFeePerGas,
|
||||
maxPriorityFeePerGas: prefs.priorityFee,
|
||||
});
|
||||
|
||||
return {
|
||||
...estimate,
|
||||
capMaxFee: maxFee,
|
||||
effectivePrice: effective,
|
||||
humanCost: `${estimateTxCostEth(prefs.gasLimit, effective)} ETH`,
|
||||
};
|
||||
}
|
||||
|
||||
const quote = quoteUserTransaction(
|
||||
{ baseFeePerGas: 50000000000n },
|
||||
{ priorityFee: 2000000000n, gasLimit: 21000n }
|
||||
);
|
||||
// { ..., humanCost: "0.000021 * effectivePrice / 10^18 ETH" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Returns |
|
||||
|---|---|---|
|
||||
| `estimateGas` | `(params: GasEstimateParams) => GasEstimate` | full estimate |
|
||||
| `calculateMaxFeePerGas` | `(baseFee: bigint, priorityFee: bigint) => bigint` | `2 * base + priority` |
|
||||
| `calculateEffectiveGasPrice` | `(baseFee: bigint, maxFee: bigint, priorityFee: bigint) => bigint` | `min(maxFee, baseFee + priorityFee)` |
|
||||
| `formatGwei` | `(weiValue: bigint) => string` | decimal gwei, signed, trimmed |
|
||||
| `estimateTxCostEth` | `(gasLimit: bigint, gasPriceWei: bigint) => string` | decimal ETH, trimmed |
|
||||
|
||||
### `GasEstimate` shape
|
||||
|
||||
```ts
|
||||
interface GasEstimate {
|
||||
gasLimit: bigint;
|
||||
gasPrice: bigint; // legacy override or maxFeePerGas
|
||||
maxFeePerGas: bigint; // baseFee + priorityFee
|
||||
maxPriorityFeePerGas: bigint;
|
||||
totalCost: bigint; // gasLimit * gasPrice
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `estimateGas`'s `maxFeePerGas` is computed as `baseFee + priorityFee` rather than the EIP-1559 ceiling (`2 * base + priority`) — the ceiling is computed separately via `calculateMaxFeePerGas` because some workflows want the conservative target (`base + priority`) while others want the worst-case ceiling. Callers wanting the ceiling can pass it via `legacyGasPrice` to override `gasPrice`.
|
||||
- `formatGwei` divides by `GWEI = 10^9n` and uses `padStart(9, "0")` to render the fractional part. Trailing zeros are trimmed with `/0+$/` so `1100000000n` becomes `"1.1"`, not `"1.100000000"`.
|
||||
- `estimateTxCostEth` divides by `10^18n` and uses `padStart(18, "0")` for the fractional ETH part. The same trailing-zero trim applies.
|
||||
- Negative wei values in `formatGwei` are supported — the absolute value drives the math and the sign is reapplied at the end.
|
||||
- `estimateTxCostEth` does not currently support negative inputs (gas refunds are out of scope for this primitive); the upstream library's behaviour is preserved bit-for-bit.
|
||||
- EIP-4844 blob-gas helpers are **not** included — `calculateBlobGasPrice` and friends land in `add-cryptocurrency-blob-gas` as called out in the enterprise audit (`enterprise/cryptocurrency.md` §3).
|
||||
- All arithmetic is `bigint`-pure; `formatGwei` and `estimateTxCostEth` are the only places where strings are produced, and even those use `BigInt.prototype.toString` exclusively.
|
||||
64
docs/money/finance/cryptocurrency/src/tokens/token-data.md
Normal file
64
docs/money/finance/cryptocurrency/src/tokens/token-data.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# token-data
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the `COMMON_TOKENS` constant: a curated list of eight well-known ERC-20 tokens on Ethereum mainnet — USDT, USDC, DAI, WBTC, WETH, LINK, UNI, MATIC. The list is used as the default seed for `TokenRegistry` when constructed with `preload = true` (the default). Consumers that want a richer registry can construct `new TokenRegistry(false)` and call `addToken` themselves, or build on top of `COMMON_TOKENS` by spreading the array.
|
||||
|
||||
This module exists as a static export so consumers can dead-code-eliminate it when they don't need a token registry; the entries here are byte-identical to upstream so any consumer relying on the addresses or decimals keeps working.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight well-known mainnet ERC-20s: USDT, USDC, DAI, WBTC, WETH, LINK, UNI, MATIC.
|
||||
- Every entry carries `address`, `symbol`, `decimals`, `chainId` (always `"ethereum"`), and `name`.
|
||||
- Stablecoins use 6 decimals (USDT, USDC), WBTC uses 8, the rest use 18.
|
||||
- Pure data export — no runtime initialisation, no side effects.
|
||||
- Mutable array — consumers can spread it to build their own seeded registries.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { COMMON_TOKENS } from "@suaveplan/cryptocurrency";
|
||||
|
||||
const usdt = COMMON_TOKENS.find((t) => t.symbol === "USDT");
|
||||
// { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", symbol: "USDT", decimals: 6, ... }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
COMMON_TOKENS,
|
||||
type TokenInfo,
|
||||
TokenRegistry,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
const myTokens: TokenInfo[] = [
|
||||
...COMMON_TOKENS,
|
||||
{
|
||||
address: "0x111111111117dC0aa78b770fA6A738034120C302",
|
||||
symbol: "1INCH",
|
||||
decimals: 18,
|
||||
chainId: "ethereum",
|
||||
name: "1inch",
|
||||
},
|
||||
];
|
||||
|
||||
const registry = new TokenRegistry(false);
|
||||
for (const token of myTokens) registry.addToken(token);
|
||||
|
||||
const oneInch = registry.getToken("ethereum", "0x111111111117dC0aa78b770fA6A738034120C302");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Notes |
|
||||
|---|---|---|
|
||||
| `COMMON_TOKENS` | `TokenInfo[]` | 8 entries; mainnet ERC-20s only |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The array is `const`-bound but **not** `as const` — entries are mutable so callers can spread-merge their own overrides without TypeScript widening complaints.
|
||||
- All addresses are EIP-55-checksummed (the canonical upstream representation) — `TokenRegistry.getToken` normalises lookups to lowercase, so consumers can pass any casing.
|
||||
- Adding a new token to `COMMON_TOKENS` requires no other code changes — the token is automatically picked up by `TokenRegistry`'s default preload.
|
||||
- The list is intentionally short. A more comprehensive registry (Uniswap token list, CoinGecko export) belongs in a downstream `@suaveplan/cryptocurrency-tokens` package; keeping the core curated avoids shipping a 200KB JSON blob in the universal package.
|
||||
- All entries are on `ethereum` chainId; multi-chain entries (USDC on Polygon, USDT on BSC, etc.) are deliberately out of scope for the core list — they belong to chain-specific catalogues.
|
||||
@@ -0,0 +1,99 @@
|
||||
# token-registry
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the `TokenRegistry` class for adding, retrieving, and searching ERC-20 / token information across chains, plus the `formatTokenBalance` helper for converting raw bigint balances into human-readable decimal strings. The registry is a thin `Map`-backed in-memory store keyed by `${chainId}:${address.toLowerCase()}`, so lookups are case-insensitive on the address — callers don't have to pre-normalise.
|
||||
|
||||
`TokenRegistry` is intentionally store-agnostic in its API but in-memory in its implementation. An RPC-backed `OnChainTokenRegistry` that dynamically fetches `decimals()` / `symbol()` from chain via an injected RPC client is called out in the enterprise audit (`enterprise/cryptocurrency.md` §4) but lands in a follow-up change.
|
||||
|
||||
## Features
|
||||
|
||||
- `addToken(token)` — upsert by chain + address.
|
||||
- `getToken(chainId, address)` — case-insensitive lookup.
|
||||
- `getTokensByChain(chainId)` — filtered list of all tokens on a chain.
|
||||
- `searchBySymbol(query)` — case-insensitive substring match on `symbol`.
|
||||
- `searchByName(query)` — case-insensitive substring match on `name`.
|
||||
- `getAllTokens()` — snapshot array of every registered token.
|
||||
- `size` accessor for fast count.
|
||||
- Constructor accepts `preload = true` (default) to seed with `COMMON_TOKENS`; `false` starts empty.
|
||||
- `formatTokenBalance(rawBalance, decimals)` — bigint → decimal string with sign + trailing-zero trim.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { formatTokenBalance, TokenRegistry } from "@suaveplan/cryptocurrency";
|
||||
|
||||
const registry = new TokenRegistry();
|
||||
const usdt = registry.getToken(
|
||||
"ethereum",
|
||||
"0xdAC17F958D2ee523a2206206994597C13D831ec7"
|
||||
);
|
||||
const balance = formatTokenBalance(1500000n, usdt?.decimals ?? 6);
|
||||
// "1.5"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatTokenBalance,
|
||||
type TokenInfo,
|
||||
TokenRegistry,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
class PortfolioView {
|
||||
private readonly registry = new TokenRegistry();
|
||||
|
||||
public addCustomTokens(tokens: TokenInfo[]) {
|
||||
for (const t of tokens) this.registry.addToken(t);
|
||||
}
|
||||
|
||||
public formatHolding(
|
||||
chainId: TokenInfo["chainId"],
|
||||
address: string,
|
||||
rawBalance: bigint
|
||||
): string {
|
||||
const token = this.registry.getToken(chainId, address);
|
||||
if (!token) return `${rawBalance} (unknown token)`;
|
||||
const balance = formatTokenBalance(rawBalance, token.decimals);
|
||||
return `${balance} ${token.symbol}`;
|
||||
}
|
||||
|
||||
public stablecoinHoldings(chainId: TokenInfo["chainId"]) {
|
||||
return this.registry
|
||||
.getTokensByChain(chainId)
|
||||
.filter((t) => ["USDT", "USDC", "DAI"].includes(t.symbol));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TokenRegistry`
|
||||
|
||||
| Method | Signature | Notes |
|
||||
|---|---|---|
|
||||
| constructor | `(preload?: boolean = true)` | `false` starts empty |
|
||||
| `addToken` | `(token: TokenInfo) => void` | upsert by `${chainId}:${address.toLowerCase()}` |
|
||||
| `getToken` | `(chainId: ChainId, address: string) => TokenInfo | undefined` | case-insensitive address |
|
||||
| `getTokensByChain` | `(chainId: ChainId) => TokenInfo[]` | filtered snapshot |
|
||||
| `searchBySymbol` | `(query: string) => TokenInfo[]` | case-insensitive substring |
|
||||
| `searchByName` | `(query: string) => TokenInfo[]` | case-insensitive substring |
|
||||
| `getAllTokens` | `() => TokenInfo[]` | snapshot of every entry |
|
||||
| `size` | accessor `number` | fast count |
|
||||
|
||||
### `formatTokenBalance`
|
||||
|
||||
| Signature | Returns |
|
||||
|---|---|
|
||||
| `(rawBalance: bigint, decimals: number) => string` | decimal string, signed, trimmed |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The internal `Map` key includes both `chainId` and `address.toLowerCase()` so multi-chain tokens with the same address (USDC on ethereum + USDC on polygon — wait, these have different addresses, but the pattern matters for sidechains) don't collide. The lowercase normalisation means callers never need to pre-checksum.
|
||||
- `addToken` is an upsert — adding a token with an `(chainId, address)` pair that already exists replaces the entry. This is the contract the test suite pins.
|
||||
- `searchBySymbol` matches against the uppercased symbol; `searchByName` matches against the lowercased name. Both are substring matches via `String.prototype.includes`, not regex, so the queries are literal — callers cannot pass regex metacharacters and expect them to be honoured.
|
||||
- `formatTokenBalance` short-circuits to `rawBalance.toString()` when `decimals === 0` (no decimal point needed). For non-zero decimals it pads the fractional part with leading zeros, trims trailing zeros, and re-applies the sign. The `|| "0"` fallback handles the case where the fractional part is entirely zero after trimming.
|
||||
- `formatTokenBalance` mirrors `formatGwei` and `estimateTxCostEth` in `gas-estimator.ts` — same bigint-only arithmetic, same trim algorithm. The duplication is intentional; refactoring to a shared helper would obscure the per-call-site decimal constant.
|
||||
- Token-data updates (adding `LINK`, removing `MATIC`) are isolated to `token-data.ts`; this module never references the constants directly.
|
||||
- An on-chain RPC-backed registry is scoped out per the proposal — it lands in `add-cryptocurrency-on-chain-tokens` once an `RpcClient` abstraction lands in `@suaveplan/types`.
|
||||
108
docs/money/finance/cryptocurrency/src/transaction/tx-decoder.md
Normal file
108
docs/money/finance/cryptocurrency/src/transaction/tx-decoder.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# tx-decoder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` classifies a transaction hash into one of `"evm" | "utxo" | "solana" | "unknown"` and returns the set of chains the hash could plausibly belong to. `extractSelector`, `lookupSelector`, and `decodeCalldata` work together to identify a function call from raw calldata when the called function is in the curated `KNOWN_SELECTORS` table (eight well-known ERC-20 / ERC-721 functions).
|
||||
|
||||
This module is a deliberate MVP: it does not decode argument bytes (ABI-aware decoding is scoped out per `enterprise/cryptocurrency.md` §3) and does not consult an online selector database (`4byte.directory` / `openchain.xyz`). Both gaps land in the `add-cryptocurrency-abi` follow-up change.
|
||||
|
||||
## Features
|
||||
|
||||
- `decodeTxHash(hash)` — classifies EVM (0x + 64 hex), UTXO (64 hex), Solana (base58 87-88 char) hashes and returns `TxMetadata { hash, chains, valid, hashType, hashHex? }`.
|
||||
- `extractSelector(calldata)` — extracts the first 10 characters of the trimmed lowercased calldata when it looks like a function call; returns `undefined` otherwise.
|
||||
- `lookupSelector(selector)` — case-insensitive lookup against the `KNOWN_SELECTORS` table.
|
||||
- `decodeCalldata(calldata)` — composition of `extractSelector` then `lookupSelector`.
|
||||
- `KNOWN_SELECTORS` — curated list of 8 well-known ERC-20 / ERC-721 function signatures.
|
||||
- All inputs are trimmed; all matching is case-insensitive on hex.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeCalldata,
|
||||
decodeTxHash,
|
||||
extractSelector,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
const tx = decodeTxHash(`0x${"a".repeat(64)}`);
|
||||
// { hash: "0xaa...", chains: ["ethereum", ...], valid: true, hashType: "evm", hashHex: "aa..." }
|
||||
|
||||
const sel = extractSelector("0xa9059cbb000000000000000000000000...");
|
||||
// "0xa9059cbb"
|
||||
|
||||
const fn = decodeCalldata("0xa9059cbb000000000000000000000000...");
|
||||
// { selector: "0xa9059cbb", name: "transfer", signature: "transfer(address,uint256)" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeCalldata,
|
||||
type FunctionSignature,
|
||||
KNOWN_SELECTORS,
|
||||
lookupSelector,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
// Extend the curated table with project-specific signatures via in-memory composition
|
||||
function makeSelectorResolver(extras: FunctionSignature[]) {
|
||||
const merged = new Map<string, FunctionSignature>();
|
||||
for (const s of [...KNOWN_SELECTORS, ...extras]) {
|
||||
merged.set(s.selector.toLowerCase(), s);
|
||||
}
|
||||
return (selectorOrCalldata: string): FunctionSignature \| undefined => {
|
||||
const baseline = decodeCalldata(selectorOrCalldata) ??
|
||||
lookupSelector(selectorOrCalldata);
|
||||
if (baseline) return baseline;
|
||||
// Try the merged table as fallback for the project-specific extras
|
||||
const normalised = selectorOrCalldata.toLowerCase().slice(0, 10);
|
||||
return merged.get(normalised);
|
||||
};
|
||||
}
|
||||
|
||||
const resolve = makeSelectorResolver([
|
||||
{
|
||||
selector: "0xdeadbeef",
|
||||
name: "myCustom",
|
||||
signature: "myCustom(bytes32,address)",
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
This pattern keeps the package's curated `KNOWN_SELECTORS` immutable while letting projects layer their own signatures on top — no need for a mutable global registry inside the package.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Notes |
|
||||
|---|---|---|
|
||||
| `KNOWN_SELECTORS` | `FunctionSignature[]` | 8 well-known selectors (transfer, approve, transferFrom, balanceOf, allowance, totalSupply, safeTransferFrom, setApprovalForAll) |
|
||||
| `decodeTxHash` | `(hash: string) => TxMetadata` | classifies EVM / UTXO / Solana hashes |
|
||||
| `lookupSelector` | `(selector: string) => FunctionSignature \| undefined` | case-insensitive on selector |
|
||||
| `extractSelector` | `(calldata: string) => string \| undefined` | first 10 chars of `0x` + ≥ 8 hex chars |
|
||||
| `decodeCalldata` | `(calldata: string) => FunctionSignature \| undefined` | composition of extract + lookup |
|
||||
|
||||
### `TxMetadata` shape
|
||||
|
||||
```ts
|
||||
interface TxMetadata {
|
||||
hash: string;
|
||||
chains: ChainId[];
|
||||
valid: boolean;
|
||||
hashType: "evm" | "utxo" | "solana" | "unknown";
|
||||
hashHex?: string; // populated for evm / utxo paths
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The hash matchers are anchored regexes (`^...$`) to prevent partial matches. EVM (`0x` + 64 hex) is checked first because it would also match the UTXO pattern if the `0x` prefix were optional. The Solana base58 pattern excludes ambiguous characters (`0`, `O`, `I`, `l`) so it cannot collide with the hex patterns.
|
||||
- `hashHex` is omitted for the Solana path (base58 is not hex). For the EVM path, the prefix is stripped; for the UTXO path, the whole input is lowercased.
|
||||
- `extractSelector` requires `0x` + at least 8 hex characters — calldata containing only a selector (`"0xa9059cbb"`, 10 chars) qualifies; anything shorter is rejected. The slice always takes 10 chars (`"0x"` + 8 hex), even if more are present.
|
||||
- `KNOWN_SELECTORS` is a mutable array (not `as const`) so consumers can spread it into custom collections. Sharing the array reference with callers that mutate it would corrupt the package's behaviour, but that is a misuse not worth defending against — `Object.freeze` would lose the spread-friendly mutability without preventing reassignment of indexed entries' fields anyway.
|
||||
- The curated selector list is deliberately small. A comprehensive registry (4byte.directory export, openchain.xyz dump) belongs in a downstream `@suaveplan/cryptocurrency-selectors` package; keeping the core curated avoids shipping a ~50MB JSON blob.
|
||||
- ABI-aware argument decoding (dynamic types, tuples, nested arrays) is **not** in this module. It lands in `add-cryptocurrency-abi` along with the pluggable online resolver interface — see `enterprise/cryptocurrency.md` §3.
|
||||
- The `hashType: "unknown"` branch returns `chains: []` and `valid: false` — callers should dispatch on `valid` first, then on `hashType` for the success cases.
|
||||
95
docs/money/finance/cryptocurrency/src/types/crypto.types.md
Normal file
95
docs/money/finance/cryptocurrency/src/types/crypto.types.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# crypto.types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the type-only foundation of `@suaveplan/cryptocurrency`. It declares every cross-cutting type the package exposes — the `ChainId` union of supported networks, the `ChainInfo` metadata record, the `ChainRegistryAdapter` async contract, validation result shapes for addresses and transaction hashes, gas-estimation inputs and outputs, token-registry records, transaction metadata, and the `FunctionSignature` calldata-decoder record. Importing from `@suaveplan/cryptocurrency/types/crypto.types` keeps consumers' bundles free of runtime code when they only need typings.
|
||||
|
||||
The types here drive the entire public surface of the package: every helper, every class, every error path references one or more of them. Keeping them in a single, dependency-free module guarantees there is one canonical home for the contract — no duplicate `ChainInfo` shapes drifting between modules, no fork between the validators and the registry.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChainId` union covering the 15 mainnet networks the package validates against.
|
||||
- `AddressType` discriminant (`"base58" | "bech32" | "hex" | "bech32m" | "ss58"`).
|
||||
- `ChainInfo` metadata record with optional `evmChainId` distinguishing EVM from non-EVM chains.
|
||||
- `ChainRegistryAdapter` async contract for pluggable chain storage.
|
||||
- Validation result shapes (`AddressValidation`, `TxHashValidation`) returning structured `valid` / `chains` / `error` data rather than throwing.
|
||||
- Gas-estimation contract (`GasEstimate`, `GasEstimateParams`) with `bigint` throughout to avoid number-precision pitfalls.
|
||||
- Token-registry record (`TokenInfo`) and transaction metadata (`TxMetadata`) with `hashType` discriminant.
|
||||
- `FunctionSignature` 4-byte selector record for the calldata decoder.
|
||||
- Zero runtime code — pure `.d.ts` after build.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ChainId, ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
|
||||
|
||||
const id: ChainId = "ethereum";
|
||||
const info: ChainInfo = {
|
||||
id,
|
||||
name: "Ethereum",
|
||||
nativeSymbol: "ETH",
|
||||
evmChainId: 1,
|
||||
addressType: "hex",
|
||||
explorerUrl: "https://etherscan.io",
|
||||
decimals: 18,
|
||||
testnet: false,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ChainInfo,
|
||||
ChainRegistryAdapter,
|
||||
} from "@suaveplan/cryptocurrency/types/crypto.types";
|
||||
|
||||
class DbBackedRegistry implements ChainRegistryAdapter {
|
||||
public constructor(private readonly db: { query: (sql: string) => Promise<ChainInfo[]> }) {}
|
||||
|
||||
public async getChain(id: ChainInfo["id"]) {
|
||||
const rows = await this.db.query(`select * from chains where id = '${id}'`);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
public async getAllChains() {
|
||||
return this.db.query("select * from chains");
|
||||
}
|
||||
|
||||
public async getChainByEvmId(evmChainId: number) {
|
||||
const rows = await this.db.query(`select * from chains where evm_chain_id = ${evmChainId}`);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
public async registerChain(chain: ChainInfo) {
|
||||
await this.db.query(`insert into chains values (${JSON.stringify(chain)})`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The adapter contract is async by design so callers can plug in IndexedDB, SurrealDB, or HTTP-backed implementations without changing the consumer-facing API.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ChainId` | union type | 15 mainnet chain ids |
|
||||
| `AddressType` | union type | discriminant on `ChainInfo.addressType` |
|
||||
| `ChainInfo` | interface | chain metadata; `evmChainId` optional, `testnet` always `false` for built-ins |
|
||||
| `CryptoUnit` | interface | unit definition (`name`, `decimals`, `symbol`) |
|
||||
| `AddressValidation` | interface | `{ valid, normalized?, chains, error? }` |
|
||||
| `TxHashValidation` | interface | `{ valid, chains, error? }` |
|
||||
| `TokenInfo` | interface | ERC-20 record; `address` keys are normalised case-insensitively by `TokenRegistry` |
|
||||
| `GasEstimate` | interface | bigint fields only |
|
||||
| `GasEstimateParams` | interface | inputs to `estimateGas`; `legacyGasPrice` optional |
|
||||
| `TxMetadata` | interface | `hashType: "evm" \| "utxo" \| "solana" \| "unknown"` |
|
||||
| `FunctionSignature` | interface | calldata-selector record |
|
||||
| `ChainRegistryAdapter` | interface | async chain-storage contract |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- No inline object shapes in interface bodies — every nested type is a named declaration (CLAUDE.md rule §11).
|
||||
- `AddressType` is a separate union so consumers can dispatch on it without re-declaring the variants.
|
||||
- `ChainInfo.evmChainId` is optional rather than `number | null` to keep the JSON serialisation clean and match upstream behaviour.
|
||||
- The validators return structured `AddressValidation` / `TxHashValidation` records rather than throwing — callers that prefer typed throws can wrap the result and raise `CryptocurrencyError({ code: CRYPTOCURRENCY.INVALID_ADDRESS | INVALID_HASH })` themselves.
|
||||
- Every interface field is documented inline so the generated `.d.ts` carries JSDoc to consumers' IDEs.
|
||||
97
docs/money/finance/cryptocurrency/src/units/converter.md
Normal file
97
docs/money/finance/cryptocurrency/src/units/converter.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# converter (units)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates over the three built-in currency registries (ETH: wei / kwei / mwei / gwei / microether / milliether / ether; BTC: satoshi / microbitcoin / millibitcoin / bitcoin; SOL: lamport / sol) and uses `bigint` arithmetic end-to-end so callers never lose precision at the `Number` boundary. Convenience wrappers (`weiToEther`, `etherToWei`, `gweiToEther`, `satoshiToBitcoin`, `bitcoinToSatoshi`, `lamportToSol`) are thin specialisations for the common operations. `getUnits(currency)` returns the full registry for a currency so UIs can enumerate denominations.
|
||||
|
||||
Errors are typed: `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CURRENCY })` for currencies outside ETH/BTC/SOL, `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_UNIT })` for unrecognised denominations.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic `convertUnits(value, fromUnit, toUnit, currency)` across ETH / BTC / SOL.
|
||||
- Six convenience wrappers for common conversions.
|
||||
- `getUnits(currency)` for enumerating registered denominations.
|
||||
- Bigint-pure arithmetic — no `Number` boundary on the hot path.
|
||||
- Case-insensitive currency tags.
|
||||
- Typed `CryptocurrencyError` with `currency` / `unit` discriminants for programmatic dispatch.
|
||||
- Universal — no `node:*` / `bun:*` / `crypto` imports.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
convertUnits,
|
||||
etherToWei,
|
||||
satoshiToBitcoin,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
convertUnits("1", "ether", "wei", "ETH"); // "1000000000000000000"
|
||||
etherToWei("1"); // "1000000000000000000"
|
||||
satoshiToBitcoin("100000000"); // "1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CRYPTOCURRENCY_CODES,
|
||||
CryptocurrencyError,
|
||||
convertUnits,
|
||||
getUnits,
|
||||
} from "@suaveplan/cryptocurrency";
|
||||
|
||||
function safeConvert(
|
||||
value: string,
|
||||
fromUnit: string,
|
||||
toUnit: string,
|
||||
currency: string
|
||||
): { ok: true; value: string } | { ok: false; reason: string } {
|
||||
try {
|
||||
return { ok: true, value: convertUnits(value, fromUnit, toUnit, currency) };
|
||||
} catch (err) {
|
||||
if (err instanceof CryptocurrencyError) {
|
||||
switch (err.code) {
|
||||
case CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY:
|
||||
return { ok: false, reason: `unsupported currency: ${err.currency}` };
|
||||
case CRYPTOCURRENCY_CODES.UNKNOWN_UNIT:
|
||||
return { ok: false, reason: `unsupported unit '${err.unit}' for ${err.currency}` };
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const supportedEth = getUnits("ETH");
|
||||
// [{ name: "wei", decimals: 0, symbol: "wei" }, ..., { name: "ether", decimals: 18, symbol: "ETH" }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Notes |
|
||||
|---|---|---|
|
||||
| `convertUnits` | `(value: string \| bigint, fromUnit: string, toUnit: string, currency: string) => string` | throws typed `CryptocurrencyError` for unknown currency / unit |
|
||||
| `weiToEther` | `(wei: string \| bigint) => string` | convenience wrapper |
|
||||
| `etherToWei` | `(ether: string \| bigint) => string` | convenience wrapper |
|
||||
| `gweiToEther` | `(gwei: string \| bigint) => string` | convenience wrapper |
|
||||
| `satoshiToBitcoin` | `(satoshi: string \| bigint) => string` | convenience wrapper |
|
||||
| `bitcoinToSatoshi` | `(btc: string \| bigint) => string` | convenience wrapper |
|
||||
| `lamportToSol` | `(lamport: string \| bigint) => string` | convenience wrapper |
|
||||
| `getUnits` | `(currency: string) => CryptoUnit[]` | returns `[]` for unknown currencies (no throw) |
|
||||
|
||||
### Error contract
|
||||
|
||||
- `CryptocurrencyError({ code: "CRYPTOCURRENCY.UNKNOWN_CURRENCY", currency })` — currency not in ETH / BTC / SOL (case-insensitive comparison after `toUpperCase`).
|
||||
- `CryptocurrencyError({ code: "CRYPTOCURRENCY.UNKNOWN_UNIT", currency, unit })` — unit not in the currency's registry.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `parseToBigInt` helper takes the integer part of a decimal string input via `String.prototype.split(".")[0]`. Fractional sub-units are not representable in `bigint`, and the converter's input semantics are unit-relative ("the value is already in `fromUnit`"), so dropping the fractional part matches the upstream contract.
|
||||
- The conversion math distinguishes three cases: same decimals (no-op), coarser-to-finer (multiply by `10^delta`), finer-to-coarser (divide via `formatBigIntWithDecimals`). The third case is the only one that produces a fractional result string.
|
||||
- `formatBigIntWithDecimals` mirrors the same trim-trailing-zeros algorithm used by `formatGwei`, `estimateTxCostEth`, and `formatTokenBalance`. The duplication is intentional — extracting a shared helper would couple modules that have no other relationship.
|
||||
- `convertUnits` uppercases the currency tag before lookup, so `"btc"`, `"BTC"`, `"Btc"` all resolve to the same registry. The `currency` discriminant on the thrown error uses the **original** caller-supplied string for `UNKNOWN_CURRENCY` (since the lookup hadn't succeeded yet) and the **normalised** uppercase form for `UNKNOWN_UNIT` (since by then the currency is known good).
|
||||
- `getUnits` returns `[]` rather than throwing for unknown currencies — this matches the upstream contract and lets UIs render "no denominations" without try-catch ceremony.
|
||||
- The Greek `µ` symbol in the `microether` / `microbitcoin` definitions is the actual Unicode `MICRO SIGN` (U+00B5) — the file's UTF-8 encoding preserves it. Bundlers that don't handle UTF-8 source will mangle the symbol, but every modern toolchain handles it correctly.
|
||||
Reference in New Issue
Block a user