Files
2026-05-27 23:29:53 +01:00

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, 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"