Initial Commit
This commit is contained in:
294
openspec/specs/cryptocurrency/spec.md
Normal file
294
openspec/specs/cryptocurrency/spec.md
Normal file
@@ -0,0 +1,294 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: cryptocurrency (cryptocurrency)
|
||||
|
||||
**Package**: `@suaveplan/cryptocurrency`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL ship as a universal-tier package — no imports from `node:*`, `bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto` in any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. The `package.json` SHALL NOT declare a `"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL declare `"sideEffects": false`. The package SHALL NOT use `performance.now()`, `Date.now()`, or any other ambient global beyond plain ECMAScript built-ins (`BigInt`, `Map`, `RegExp`, `Object`, `Array`, `String`, `Number`).
|
||||
|
||||
#### Scenario: no platform imports in src/
|
||||
|
||||
- **WHEN** a CI step runs `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/cryptocurrency/src/`
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `src/units/converter.test.ts::"converter source has no node/bun imports (tier purity)"` AND `src/chains/chain-registry.test.ts::"chain-registry source has no node/bun imports (tier purity)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable CRYPTOCURRENCY_CODES error registry
|
||||
|
||||
The package SHALL declare an error-code registry via `defineCodes("CRYPTOCURRENCY", [...])` containing exactly the codes `UNKNOWN_CHAIN`, `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT`, `INVALID_ADDRESS`, `INVALID_HASH`. The package SHALL export a `CryptocurrencyError extends SuaveplanError<CryptocurrencyErrorCode>` carrying optional `chainId` / `currency` / `unit` discriminants. Every public method that previously threw a plain `Error` SHALL now throw a `CryptocurrencyError` with one of the registered codes. `INVALID_ADDRESS` and `INVALID_HASH` SHALL be exposed for application-tier consumers wishing to wrap the validators' structured returns with typed errors; they SHALL be reachable via direct `CryptocurrencyError` construction and exercised by the test suite.
|
||||
|
||||
#### Scenario: every CRYPTOCURRENCY.* code is exposed and constructible
|
||||
|
||||
- **WHEN** the test suite constructs `new CryptocurrencyError(code, msg)` for each of the five registered codes
|
||||
- **THEN** each instance is `instanceof CryptocurrencyError`, `instanceof SuaveplanError`, and `error.code` matches the expected `CRYPTOCURRENCY.*` string
|
||||
|
||||
#### Scenario: SuaveplanError contract preserved
|
||||
|
||||
- **WHEN** a `CryptocurrencyError` is thrown with a `cause` (e.g. wrapping an adapter exception)
|
||||
- **THEN** the resulting error exposes the original on `error.cause` and survives the `SuaveplanError` `toWire()` / `fromWire()` round-trip
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every CRYPTOCURRENCY.* code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"CryptocurrencyError preserves cause and SuaveplanError contract"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: validateAddress detects matching chains and EIP-55 checksums EVM input
|
||||
|
||||
The `validateAddress(address)` function SHALL return an `AddressValidation` object whose `valid` is true when the input matches at least one registered chain regex (EVM hex, Bitcoin legacy/segwit, Litecoin legacy/segwit, Solana base58, TRON base58, Dogecoin base58, Cardano bech32, Cosmos bech32, Polkadot ss58), with `chains` listing every matching `ChainId`. For EVM-matching inputs the function SHALL populate `normalized` with the simplified positional EIP-55 checksum of the address. The function SHALL trim leading/trailing whitespace before validation. The function SHALL return `{ valid: false, chains: [], error: "Address must be a non-empty string" }` for empty / null / non-string input, and `{ valid: false, chains: [], error: "Address does not match any known format" }` when no pattern matches.
|
||||
|
||||
#### Scenario: EVM address matches all seven EVM chains and yields a checksummed normalized form
|
||||
|
||||
- **WHEN** `validateAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28")` is called
|
||||
- **THEN** `result.valid === true`, `result.chains` contains `"ethereum"`, `"polygon"`, `"arbitrum"`, `"optimism"`, `"avalanche"`, `"bsc"`, `"base"`, and `result.normalized` is the EIP-55 checksummed string
|
||||
|
||||
#### Scenario: Bitcoin bech32 matches only bitcoin
|
||||
|
||||
- **WHEN** `validateAddress("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")` is called
|
||||
- **THEN** `result.valid === true` and `result.chains` equals `["bitcoin"]`
|
||||
|
||||
#### Scenario: malformed input is rejected with the documented error message
|
||||
|
||||
- **WHEN** `validateAddress("not-an-address")` is called
|
||||
- **THEN** `result.valid === false` and `result.error === "Address does not match any known format"`
|
||||
|
||||
**Verified by:** `src/address/validator.test.ts::"validates EVM address"` AND `src/address/validator.test.ts::"validates Bitcoin Segwit address (bc1)"` AND `src/address/validator.test.ts::"rejects invalid address"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: validateAddressForChain narrows result chains
|
||||
|
||||
The `validateAddressForChain(address, chainId)` function SHALL return the result of `validateAddress(address)` with `chains` narrowed to `[chainId]` when the address validates for that chain. When the address fails `validateAddress` the original failure SHALL be propagated. When the address validates but does not include `chainId` in its matched set, the function SHALL return `{ valid: false, chains: <original-matches>, error: \`Address is not valid for ${chainId}\` }`.
|
||||
|
||||
#### Scenario: EVM address validated for a specific EVM chain
|
||||
|
||||
- **WHEN** `validateAddressForChain("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28", "ethereum")` is called
|
||||
- **THEN** `result.valid === true` and `result.chains` equals `["ethereum"]`
|
||||
|
||||
#### Scenario: EVM address rejected for non-EVM chain
|
||||
|
||||
- **WHEN** `validateAddressForChain("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28", "bitcoin")` is called
|
||||
- **THEN** `result.valid === false` and `result.error` contains the substring `"not valid for bitcoin"`
|
||||
|
||||
**Verified by:** `src/address/validator.test.ts::"validates EVM address for ethereum"` AND `src/address/validator.test.ts::"rejects EVM address for bitcoin"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: validateTxHash recognises EVM, UTXO, and Solana hash shapes
|
||||
|
||||
The `validateTxHash(hash)` function SHALL recognise three hash forms: EVM (`0x` + 64 hex chars) maps to the seven EVM chains; UTXO (64 hex chars, no prefix) maps to `["bitcoin", "litecoin", "dogecoin"]`; Solana (base58 87-88 chars) maps to `["solana"]`. Input is trimmed before matching. Empty / null / non-string input SHALL return `{ valid: false, chains: [], error: "Hash must be a non-empty string" }`. Unrecognised input SHALL return `{ valid: false, chains: [], error: "Hash does not match any known format" }`.
|
||||
|
||||
#### Scenario: EVM hash maps to all seven EVM chains
|
||||
|
||||
- **WHEN** `validateTxHash("0x" + "a".repeat(64))` is called
|
||||
- **THEN** `result.valid === true` and `result.chains` includes `"ethereum"` and `"polygon"`
|
||||
|
||||
#### Scenario: UTXO hash maps to bitcoin / litecoin / dogecoin
|
||||
|
||||
- **WHEN** `validateTxHash("a".repeat(64))` is called
|
||||
- **THEN** `result.valid === true` and `result.chains` equals `["bitcoin", "litecoin", "dogecoin"]`
|
||||
|
||||
**Verified by:** `src/address/validator.test.ts::"validates EVM tx hash (0x prefix)"` AND `src/address/validator.test.ts::"validates Bitcoin tx hash (no prefix)"` AND `src/address/validator.test.ts::"validates Solana tx signature"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CHAINS constant exposes 15 mainnet entries
|
||||
|
||||
The package SHALL export a `CHAINS: Record<ChainId, ChainInfo>` constant containing exactly the 15 mainnet entries: `bitcoin`, `ethereum`, `solana`, `polygon`, `arbitrum`, `optimism`, `avalanche`, `bsc`, `base`, `tron`, `litecoin`, `dogecoin`, `cardano`, `polkadot`, `cosmos`. Each entry SHALL declare `id`, `name`, `nativeSymbol`, `addressType`, `explorerUrl`, `decimals`, `testnet: false`. EVM chains SHALL declare a numeric `evmChainId` matching the canonical mainnet id (Ethereum 1, BSC 56, Polygon 137, Optimism 10, Base 8453, Arbitrum 42161, Avalanche 43114). Non-EVM chains SHALL omit `evmChainId`.
|
||||
|
||||
#### Scenario: every ChainId is present and well-formed
|
||||
|
||||
- **WHEN** the test asserts the keys of `CHAINS` against the 15-chain set
|
||||
- **THEN** every chain is present and every entry's `id` field equals its key
|
||||
|
||||
#### Scenario: EVM chain ids match canonical mainnet values
|
||||
|
||||
- **WHEN** the test asserts `CHAINS.ethereum.evmChainId === 1`, `CHAINS.polygon.evmChainId === 137`, `CHAINS.base.evmChainId === 8453`
|
||||
- **THEN** every assertion passes
|
||||
|
||||
**Verified by:** `src/chains/chain-data.test.ts::"CHAINS contains all 15 mainnet entries"` AND `src/chains/chain-data.test.ts::"EVM chains carry the canonical mainnet evmChainId"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: synchronous chain getters operate on the built-in CHAINS constant
|
||||
|
||||
The package SHALL export pure synchronous helpers `getChain(id)`, `getAllChainIds()`, `getEvmChains()`, `getChainByEvmId(evmChainId)`, `getExplorerAddressUrl(chainId, address)`, `getExplorerTxUrl(chainId, txHash)`. `getChain` SHALL return the `ChainInfo` directly. `getAllChainIds` SHALL return all 15 ids. `getEvmChains` SHALL return only chains with a defined `evmChainId`. `getChainByEvmId` SHALL return the matching chain or `undefined`. The explorer URL helpers SHALL return `\`${chain.explorerUrl}/address/${address}\`` / `\`${chain.explorerUrl}/tx/${txHash}\``.
|
||||
|
||||
#### Scenario: getChainByEvmId returns ethereum for 1
|
||||
|
||||
- **WHEN** `getChainByEvmId(1)` is called
|
||||
- **THEN** the result is the `ChainInfo` with `id === "ethereum"`
|
||||
|
||||
#### Scenario: getChainByEvmId returns undefined for unknown id
|
||||
|
||||
- **WHEN** `getChainByEvmId(99999)` is called
|
||||
- **THEN** the result is `undefined`
|
||||
|
||||
#### Scenario: explorer URLs concatenate template + path segment
|
||||
|
||||
- **WHEN** `getExplorerAddressUrl("ethereum", "0xabc")` and `getExplorerTxUrl("bitcoin", "abc123")` are called
|
||||
- **THEN** the results are `"https://etherscan.io/address/0xabc"` and `"https://blockchair.com/bitcoin/tx/abc123"` respectively
|
||||
|
||||
**Verified by:** `src/chains/registry.test.ts::"finds chain by EVM chain ID"` AND `src/chains/registry.test.ts::"returns undefined for unknown chain ID"` AND `src/chains/registry.test.ts::"builds address URL"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ChainRegistry async façade with adapter injection and UNKNOWN_CHAIN error
|
||||
|
||||
The package SHALL export `MemoryChainRegistry implements ChainRegistryAdapter` and `ChainRegistry` classes. `MemoryChainRegistry` SHALL preload the built-in `CHAINS` set when constructed without arguments and SHALL accept a custom `ChainInfo[]` seed otherwise. It SHALL expose async `getChain` / `getAllChains` / `getChainByEvmId` / `registerChain` methods. `ChainRegistry` SHALL default to a fresh `MemoryChainRegistry` when no adapter is injected, SHALL forward calls to the adapter, SHALL provide async `getEvmChains` / `getExplorerAddressUrl` / `getExplorerTxUrl` helpers, and SHALL raise `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })` when the adapter returns no chain for an explorer URL request. The `ChainRegistry` constructor SHALL accept an optional `LoggerLike` (no-op default) and SHALL log `chain.registered` (info) and `chain.lookup.miss` (debug) events when one is provided.
|
||||
|
||||
#### Scenario: MemoryChainRegistry preloads built-in chains
|
||||
|
||||
- **WHEN** `new MemoryChainRegistry()` is constructed and `getAllChains()` is awaited
|
||||
- **THEN** the returned array has length 15
|
||||
|
||||
#### Scenario: MemoryChainRegistry accepts a custom seed
|
||||
|
||||
- **WHEN** `new MemoryChainRegistry([customChain])` is constructed and `getAllChains()` is awaited
|
||||
- **THEN** the returned array contains exactly `[customChain]`
|
||||
|
||||
#### Scenario: ChainRegistry raises UNKNOWN_CHAIN for missing explorer lookups
|
||||
|
||||
- **WHEN** a `ChainRegistry` is constructed over an empty adapter and `getExplorerAddressUrl("ethereum", "0xabc")` is awaited
|
||||
- **THEN** the promise rejects with a `CryptocurrencyError` whose `code === "CRYPTOCURRENCY.UNKNOWN_CHAIN"` and `chainId === "ethereum"`
|
||||
|
||||
#### Scenario: ChainRegistry logs registration and lookup-miss events when a logger is provided
|
||||
|
||||
- **WHEN** a `ChainRegistry` is constructed with a recording `LoggerLike`, `registerChain` is awaited, and an unknown-chain explorer lookup is attempted
|
||||
- **THEN** the logger records a `chain.registered` info event AND a `chain.lookup.miss` debug event
|
||||
|
||||
**Verified by:** `src/chains/chain-registry.test.ts::"MemoryChainRegistry loads default chains on construction"` AND `src/chains/chain-registry.test.ts::"MemoryChainRegistry loads custom initial chains"` AND `src/chains/chain-registry.test.ts::"ChainRegistry raises CRYPTOCURRENCY.UNKNOWN_CHAIN on unknown chain"` AND `src/chains/chain-registry.test.ts::"ChainRegistry logs chain.registered and chain.lookup.miss events"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EIP-1559 gas estimation helpers
|
||||
|
||||
The package SHALL export `estimateGas(params)`, `calculateMaxFeePerGas(baseFee, priorityFee)`, `calculateEffectiveGasPrice(baseFee, maxFee, priorityFee)`, `formatGwei(weiValue)`, `estimateTxCostEth(gasLimit, gasPriceWei)`. All numeric inputs and outputs SHALL be `bigint` (no `number` boundary). `estimateGas` SHALL compute `maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas`, SHALL honour an optional `legacyGasPrice` override on `gasPrice`, and SHALL compute `totalCost = gasLimit * effectiveGasPrice`. `calculateMaxFeePerGas` SHALL implement `2 * baseFee + priorityFee`. `calculateEffectiveGasPrice` SHALL implement `min(maxFee, baseFee + priorityFee)`. `formatGwei` SHALL produce a sign-preserving decimal gwei string with trailing zeros trimmed. `estimateTxCostEth` SHALL produce a decimal ETH string from `gasLimit * gasPriceWei` divided by `10^18`.
|
||||
|
||||
#### Scenario: estimateGas with EIP-1559 inputs
|
||||
|
||||
- **WHEN** `estimateGas({ gasLimit: 21000n, baseFeePerGas: 30000000000n, maxPriorityFeePerGas: 2000000000n })` is called
|
||||
- **THEN** the result has `maxFeePerGas === 32000000000n`, `gasPrice === 32000000000n`, and `totalCost === 21000n * 32000000000n`
|
||||
|
||||
#### Scenario: estimateGas honours legacyGasPrice
|
||||
|
||||
- **WHEN** `estimateGas` is called with `legacyGasPrice: 25000000000n`
|
||||
- **THEN** `result.gasPrice === 25000000000n` and `result.totalCost === gasLimit * 25000000000n`
|
||||
|
||||
#### Scenario: formatGwei trims trailing zeros and preserves sign
|
||||
|
||||
- **WHEN** `formatGwei(2500000000n)` and `formatGwei(-2500000000n)` are called
|
||||
- **THEN** the results are `"2.5"` and `"-2.5"` respectively
|
||||
|
||||
#### Scenario: estimateTxCostEth produces ETH decimal string
|
||||
|
||||
- **WHEN** `estimateTxCostEth(21000n, 30000000000n)` is called
|
||||
- **THEN** the result is `"0.00063"`
|
||||
|
||||
**Verified by:** `src/gas/gas-estimator.test.ts::"calculates gas estimate from EIP-1559 params"` AND `src/gas/gas-estimator.test.ts::"uses legacy gas price when provided"` AND `src/gas/gas-estimator.test.ts::"formats fractional gwei"` AND `src/gas/gas-estimator.test.ts::"handles negative values"` AND `src/gas/gas-estimator.test.ts::"estimates simple transfer cost"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TokenRegistry with case-insensitive address keys + COMMON_TOKENS
|
||||
|
||||
The package SHALL export `TokenRegistry` with `addToken` / `getToken` / `getTokensByChain` / `searchBySymbol` / `searchByName` / `getAllTokens` methods and a `size` accessor. `getToken(chainId, address)` SHALL be case-insensitive on the address. The constructor SHALL accept `preload = true` (default), preloading the registry with `COMMON_TOKENS`; passing `false` SHALL start empty. `searchBySymbol` / `searchByName` SHALL perform case-insensitive substring matches. The package SHALL export `COMMON_TOKENS: TokenInfo[]` containing the well-known ERC-20s `USDT`, `USDC`, `DAI`, `WBTC`, `WETH`, `LINK`, `UNI`, `MATIC` on Ethereum mainnet. The `formatTokenBalance(rawBalance, decimals)` helper SHALL produce a decimal string with sign + trailing-zero trim; `decimals === 0` SHALL return the bigint as-is.
|
||||
|
||||
#### Scenario: preload populates COMMON_TOKENS
|
||||
|
||||
- **WHEN** `new TokenRegistry()` is constructed and `size` is read
|
||||
- **THEN** `size === COMMON_TOKENS.length`
|
||||
|
||||
#### Scenario: getToken is case-insensitive on address
|
||||
|
||||
- **WHEN** a token is added with address `"0xAbC"` and `getToken("ethereum", "0xabc")` is called
|
||||
- **THEN** the token is returned
|
||||
|
||||
#### Scenario: searchBySymbol is case-insensitive
|
||||
|
||||
- **WHEN** `searchBySymbol("usd")` is called on a preloaded registry
|
||||
- **THEN** the result includes both `USDT` and `USDC`
|
||||
|
||||
#### Scenario: formatTokenBalance handles negatives and zeros
|
||||
|
||||
- **WHEN** `formatTokenBalance(-1500000n, 6)` and `formatTokenBalance(0n, 18)` are called
|
||||
- **THEN** the results are `"-1.5"` and `"0"` respectively
|
||||
|
||||
**Verified by:** `src/tokens/token-registry.test.ts::"preloads common tokens by default"` AND `src/tokens/token-registry.test.ts::"retrieves token with case-insensitive address"` AND `src/tokens/token-registry.test.ts::"searches by symbol case-insensitively"` AND `src/tokens/token-registry.test.ts::"handles negative balances"` AND `src/tokens/token-data.test.ts::"COMMON_TOKENS contains all 8 well-known ERC-20s"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Transaction selector decoder + KNOWN_SELECTORS
|
||||
|
||||
The package SHALL export `KNOWN_SELECTORS: FunctionSignature[]` containing at minimum `transfer`, `approve`, `transferFrom`, `balanceOf`, `allowance`, `totalSupply`, `safeTransferFrom`, `setApprovalForAll`, each with its canonical 4-byte selector and signature. `lookupSelector(selector)` SHALL return the matching `FunctionSignature` (case-insensitive on the selector) or `undefined`. `extractSelector(calldata)` SHALL return the first 10 chars of the trimmed lowercased calldata when it matches `/^0x[0-9a-fA-F]{8,}$/`, otherwise `undefined`. `decodeCalldata(calldata)` SHALL compose `extractSelector` then `lookupSelector`. `decodeTxHash(hash)` SHALL return a `TxMetadata` with `hashType` ∈ `"evm" | "utxo" | "solana" | "unknown"` derived from the same matchers as `validateTxHash` and `hashHex` populated for evm/utxo paths.
|
||||
|
||||
#### Scenario: lookupSelector finds canonical ERC-20 transfer
|
||||
|
||||
- **WHEN** `lookupSelector("0xa9059cbb")` is called
|
||||
- **THEN** the result has `name === "transfer"` and `signature === "transfer(address,uint256)"`
|
||||
|
||||
#### Scenario: extractSelector rejects too-short or non-hex calldata
|
||||
|
||||
- **WHEN** `extractSelector("0xabcd")` and `extractSelector("not-hex-data")` are called
|
||||
- **THEN** both return `undefined`
|
||||
|
||||
#### Scenario: decodeTxHash classifies hash type
|
||||
|
||||
- **WHEN** `decodeTxHash("0x" + "a".repeat(64))` and `decodeTxHash("a".repeat(64))` are called
|
||||
- **THEN** the first has `hashType === "evm"` and the second has `hashType === "utxo"`
|
||||
|
||||
**Verified by:** `src/transaction/tx-decoder.test.ts::"finds transfer selector"` AND `src/transaction/tx-decoder.test.ts::"returns undefined for too short calldata"` AND `src/transaction/tx-decoder.test.ts::"distinguishes EVM (0x prefix) from UTXO"` AND `src/transaction/tx-decoder.test.ts::"contains essential ERC-20 selectors"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Unit conversion for ETH / BTC / SOL with bigint arithmetic and typed errors
|
||||
|
||||
The `convertUnits(value, fromUnit, toUnit, currency)` function SHALL convert between any two registered denominations for ETH (`wei`, `kwei`, `mwei`, `gwei`, `microether`, `milliether`, `ether`), BTC (`satoshi`, `microbitcoin`, `millibitcoin`, `bitcoin`), and SOL (`lamport`, `sol`). The `currency` argument SHALL be case-insensitive. The arithmetic SHALL use `bigint` internally. The function SHALL throw `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CURRENCY, currency })` for an unrecognised currency and `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_UNIT, currency, unit })` for an unrecognised denomination. The package SHALL export convenience wrappers `weiToEther` / `etherToWei` / `gweiToEther` / `satoshiToBitcoin` / `bitcoinToSatoshi` / `lamportToSol`. `getUnits(currency)` SHALL return the `CryptoUnit[]` registry for the currency or `[]` for unknown currencies.
|
||||
|
||||
#### Scenario: ether → wei produces 18-decimal expansion
|
||||
|
||||
- **WHEN** `convertUnits("1", "ether", "wei", "ETH")` is called
|
||||
- **THEN** the result is `"1000000000000000000"`
|
||||
|
||||
#### Scenario: satoshi → bitcoin produces 8-decimal contraction
|
||||
|
||||
- **WHEN** `convertUnits("1", "satoshi", "bitcoin", "BTC")` is called
|
||||
- **THEN** the result is `"0.00000001"`
|
||||
|
||||
#### Scenario: unknown currency raises CRYPTOCURRENCY.UNKNOWN_CURRENCY
|
||||
|
||||
- **WHEN** `convertUnits("1", "wei", "ether", "XYZ")` is called
|
||||
- **THEN** a `CryptocurrencyError` with `code === "CRYPTOCURRENCY.UNKNOWN_CURRENCY"` and `currency === "XYZ"` is thrown
|
||||
|
||||
#### Scenario: unknown unit raises CRYPTOCURRENCY.UNKNOWN_UNIT
|
||||
|
||||
- **WHEN** `convertUnits("1", "fake", "ether", "ETH")` is called
|
||||
- **THEN** a `CryptocurrencyError` with `code === "CRYPTOCURRENCY.UNKNOWN_UNIT"`, `currency === "ETH"`, and `unit === "fake"` is thrown
|
||||
|
||||
#### Scenario: getUnits returns the ETH registry
|
||||
|
||||
- **WHEN** `getUnits("ETH")` is called
|
||||
- **THEN** the result includes entries with `name === "wei"` and `name === "ether"`
|
||||
|
||||
**Verified by:** `src/units/converter.test.ts::"ether to wei"` AND `src/units/converter.test.ts::"small satoshi to bitcoin"` AND `src/units/converter.test.ts::"throws CRYPTOCURRENCY.UNKNOWN_CURRENCY on unknown currency"` AND `src/units/converter.test.ts::"throws CRYPTOCURRENCY.UNKNOWN_UNIT on unknown from unit"` AND `src/units/converter.test.ts::"returns ETH units"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per public module
|
||||
|
||||
The package's `package.json` `exports` map SHALL declare subpath entries for every public module so consumers can tree-shake: `.`, `./package.json`, `./address/validator`, `./units/converter`, `./chains/registry`, `./chains/chain-registry`, `./chains/chain-data`, `./tokens/token-registry`, `./tokens/token-data`, `./gas/gas-estimator`, `./transaction/tx-decoder`, `./types/crypto.types`, `./error-codes/error-codes`. Every subpath SHALL resolve to a built `.js` + `.d.ts` under `dist/`.
|
||||
|
||||
#### Scenario: every advertised subpath resolves after build
|
||||
|
||||
- **WHEN** the test imports each of the 11 non-root subpath exports via dynamic `import()`
|
||||
- **THEN** every import resolves and yields a module record with at least one named export
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every package.json subpath export resolves to a real module"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user