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

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,
},
});