22 KiB
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, anderror.codematches the expectedCRYPTOCURRENCY.*string
Scenario: SuaveplanError contract preserved
- WHEN a
CryptocurrencyErroris thrown with acause(e.g. wrapping an adapter exception) - THEN the resulting error exposes the original on
error.causeand survives theSuaveplanErrortoWire()/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.chainscontains"ethereum","polygon","arbitrum","optimism","avalanche","bsc","base", andresult.normalizedis the EIP-55 checksummed string
Scenario: Bitcoin bech32 matches only bitcoin
- WHEN
validateAddress("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")is called - THEN
result.valid === trueandresult.chainsequals["bitcoin"]
Scenario: malformed input is rejected with the documented error message
- WHEN
validateAddress("not-an-address")is called - THEN
result.valid === falseandresult.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 === trueandresult.chainsequals["ethereum"]
Scenario: EVM address rejected for non-EVM chain
- WHEN
validateAddressForChain("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28", "bitcoin")is called - THEN
result.valid === falseandresult.errorcontains 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 === trueandresult.chainsincludes"ethereum"and"polygon"
Scenario: UTXO hash maps to bitcoin / litecoin / dogecoin
- WHEN
validateTxHash("a".repeat(64))is called - THEN
result.valid === trueandresult.chainsequals["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
CHAINSagainst the 15-chain set - THEN every chain is present and every entry's
idfield 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
ChainInfowithid === "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")andgetExplorerTxUrl("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 andgetAllChains()is awaited - THEN the returned array has length 15
Scenario: MemoryChainRegistry accepts a custom seed
- WHEN
new MemoryChainRegistry([customChain])is constructed andgetAllChains()is awaited - THEN the returned array contains exactly
[customChain]
Scenario: ChainRegistry raises UNKNOWN_CHAIN for missing explorer lookups
- WHEN a
ChainRegistryis constructed over an empty adapter andgetExplorerAddressUrl("ethereum", "0xabc")is awaited - THEN the promise rejects with a
CryptocurrencyErrorwhosecode === "CRYPTOCURRENCY.UNKNOWN_CHAIN"andchainId === "ethereum"
Scenario: ChainRegistry logs registration and lookup-miss events when a logger is provided
- WHEN a
ChainRegistryis constructed with a recordingLoggerLike,registerChainis awaited, and an unknown-chain explorer lookup is attempted - THEN the logger records a
chain.registeredinfo event AND achain.lookup.missdebug 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, andtotalCost === 21000n * 32000000000n
Scenario: estimateGas honours legacyGasPrice
- WHEN
estimateGasis called withlegacyGasPrice: 25000000000n - THEN
result.gasPrice === 25000000000nandresult.totalCost === gasLimit * 25000000000n
Scenario: formatGwei trims trailing zeros and preserves sign
- WHEN
formatGwei(2500000000n)andformatGwei(-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 andsizeis read - THEN
size === COMMON_TOKENS.length
Scenario: getToken is case-insensitive on address
- WHEN a token is added with address
"0xAbC"andgetToken("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
USDTandUSDC
Scenario: formatTokenBalance handles negatives and zeros
- WHEN
formatTokenBalance(-1500000n, 6)andformatTokenBalance(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"andsignature === "transfer(address,uint256)"
Scenario: extractSelector rejects too-short or non-hex calldata
- WHEN
extractSelector("0xabcd")andextractSelector("not-hex-data")are called - THEN both return
undefined
Scenario: decodeTxHash classifies hash type
- WHEN
decodeTxHash("0x" + "a".repeat(64))anddecodeTxHash("a".repeat(64))are called - THEN the first has
hashType === "evm"and the second hashashType === "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
CryptocurrencyErrorwithcode === "CRYPTOCURRENCY.UNKNOWN_CURRENCY"andcurrency === "XYZ"is thrown
Scenario: unknown unit raises CRYPTOCURRENCY.UNKNOWN_UNIT
- WHEN
convertUnits("1", "fake", "ether", "ETH")is called - THEN a
CryptocurrencyErrorwithcode === "CRYPTOCURRENCY.UNKNOWN_UNIT",currency === "ETH", andunit === "fake"is thrown
Scenario: getUnits returns the ETH registry
- WHEN
getUnits("ETH")is called - THEN the result includes entries with
name === "wei"andname === "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"