# Capability Spec Delta: currency-provider-coinmarketcap (add-currency-provider-coinmarketcap) **Package**: `@suaveplan/currency-provider-coinmarketcap` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. > No exceptions. ## ADDED Requirements ### Requirement: CoinMarketCapProvider — class implementing CurrencyProvider The package SHALL export `CoinMarketCapProvider`, a class implementing `CurrencyProvider` from `@suaveplan/currency` with stable identity fields `name = "coinmarketcap"`, `supportsCrypto = true`, and `supportsFiat = false`. The constructor SHALL accept an `ApiKeyConfig` (from `@suaveplan/currency-providers`) carrying a non-empty `apiKey`, an optional `baseUrl` (default `https://pro-api.coinmarketcap.com/v1`), and an optional injectable `FetchLike` (default `globalThis.fetch`). #### Scenario: Identity fields are populated - **WHEN** a `CoinMarketCapProvider` is constructed with a valid `apiKey` - **THEN** `provider.name === "coinmarketcap"`, `provider.supportsCrypto === true`, `provider.supportsFiat === false`, and `provider.supportsVirtual` is undefined. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"exposes stable identity fields"` --- ### Requirement: Constructor rejects missing or empty API key The package SHALL raise `CurrencyProviderError(MISSING_CONFIG)` from the constructor when `config.apiKey` is the empty string or contains only whitespace. The error SHALL carry `providerName: "coinmarketcap"`. #### Scenario: Empty apiKey is rejected at construction time - **WHEN** `new CoinMarketCapProvider({ apiKey: "" })` is called - **THEN** a `CurrencyProviderError` is thrown synchronously with `code === "CURRENCY_PROVIDER.MISSING_CONFIG"` and `providerName === "coinmarketcap"`. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey is empty"` AND `src/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey is whitespace"` --- ### Requirement: fetchRates issues an authenticated quotes/latest request `fetchRates(options?)` SHALL call `GET /cryptocurrency/quotes/latest?symbol=&convert=` through the shared `httpRequest()` helper from `@suaveplan/currency-providers`. The request SHALL carry the headers `X-CMC_PRO_API_KEY: ` and `Accept: application/json`. When `options.targets` is omitted, the request SHALL query a default symbol set covering the top crypto-by-market-cap (`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`). When `options.base` is omitted, the request SHALL convert into `USD`. `options.signal` SHALL be forwarded to `httpRequest()` so caller cancellation propagates to the underlying fetch. #### Scenario: Builds the canonical URL and headers - **WHEN** `fetchRates({ base: "EUR", targets: ["BTC", "ETH"] })` is called - **THEN** the injected fetch receives a `GET` to `https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH&convert=EUR` with `X-CMC_PRO_API_KEY` and `Accept: application/json` headers. #### Scenario: Defaults targets to the top-10 symbol set and base to USD - **WHEN** `fetchRates()` is called with no options - **THEN** the request URL contains `symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT` and `convert=USD`. #### Scenario: Forwards caller AbortSignal - **WHEN** the caller aborts the signal passed via `options.signal` - **THEN** the underlying fetch rejects with the abort reason and `fetchRates` rejects. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"issues an authenticated GET to /cryptocurrency/quotes/latest"` AND `src/coinmarketcap/coinmarketcap.test.ts::"defaults to USD + top-10 symbols when no options are given"` AND `src/coinmarketcap/coinmarketcap.test.ts::"forwards the caller AbortSignal to the injected fetch"` --- ### Requirement: Non-2xx HTTP responses surface as HTTP_ERROR The package SHALL surface non-2xx responses as `CurrencyProviderError(HTTP_ERROR)` (raised by the shared `httpRequest()` helper, with `status` populated and `providerName: "coinmarketcap"`). #### Scenario: 503 maps to HTTP_ERROR with status - **WHEN** the upstream returns `503 Service Unavailable` - **THEN** `fetchRates()` rejects with `CurrencyProviderError` whose `code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, and `providerName === "coinmarketcap"`. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"surfaces non-2xx responses as HTTP_ERROR"` --- ### Requirement: Malformed JSON or missing data map raises PARSE_ERROR The package SHALL raise `CurrencyProviderError(PARSE_ERROR)` when the upstream payload is not valid JSON, when the parsed value is not an object, or when the `data` field is missing / not an object. The error SHALL carry `providerName: "coinmarketcap"`. #### Scenario: Invalid JSON body raises PARSE_ERROR - **WHEN** the upstream returns a 200 with body `"not-json"` - **THEN** `fetchRates()` rejects with `CurrencyProviderError`, `code === "CURRENCY_PROVIDER.PARSE_ERROR"`. #### Scenario: Missing data field raises PARSE_ERROR - **WHEN** the upstream returns `{ "status": { "error_code": null } }` - **THEN** `fetchRates()` rejects with `CurrencyProviderError`, `code === "CURRENCY_PROVIDER.PARSE_ERROR"`. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises PARSE_ERROR when the body is not valid JSON"` AND `src/coinmarketcap/coinmarketcap.test.ts::"raises PARSE_ERROR when the data field is missing"` --- ### Requirement: Upstream-reported errors surface as UPSTREAM_ERROR When the upstream payload carries a non-null, non-zero `status.error_code`, the package SHALL raise `CurrencyProviderError(UPSTREAM_ERROR)` carrying the upstream `status.error_message` (when present) on the `upstreamMessage` discriminant and `providerName: "coinmarketcap"`. #### Scenario: Upstream error_code populated - **WHEN** the upstream returns `{ "status": { "error_code": 1006, "error_message": "Insufficient credits" } }` - **THEN** `fetchRates()` rejects with `CurrencyProviderError`, `code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`, and `upstreamMessage === "Insufficient credits"`. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises UPSTREAM_ERROR when status.error_code is populated"` --- ### Requirement: No resolved symbols raises UNKNOWN_CURRENCY When the upstream payload contains a valid `data` map but none of the requested target symbols resolve to a quote in the configured base currency, the package SHALL raise `CurrencyProviderError(UNKNOWN_CURRENCY)` carrying `providerName: "coinmarketcap"`. #### Scenario: Data map empty for requested symbols - **WHEN** the upstream returns `{ "data": {} }` for the requested symbols - **THEN** `fetchRates()` rejects with `CurrencyProviderError`, `code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises UNKNOWN_CURRENCY when no requested symbol resolved"` --- ### Requirement: Resolved rates are inverted to base-per-unit form CoinMarketCap returns `1 crypto = X fiat`; the package SHALL invert each returned price into `1 fiat = (1 / price) crypto` and build the `RateFetchResult.rates` map keyed by symbol. The result's `base` SHALL match the requested `base`; `timestamp` SHALL be set to `Date.now()` at parse time. #### Scenario: Returned price is inverted - **WHEN** the upstream returns `{ "data": { "BTC": { "quote": { "USD": { "price": 50000 } } } } }` for `fetchRates({ base: "USD", targets: ["BTC"] })` - **THEN** the result has `base === "USD"`, `rates.BTC === 1 / 50000`, and a numeric `timestamp`. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"inverts price into base-per-unit rate"` --- ### Requirement: Custom baseUrl and injected fetch are honoured The package SHALL honour the optional `baseUrl` and `fetch` fields of `ApiKeyConfig`. When `baseUrl` is set, the request URL SHALL use it as prefix. When `fetch` is set, that closure SHALL be used in place of `globalThis.fetch`. When `fetch` is omitted, `globalThis.fetch` SHALL be the fallback. #### Scenario: Custom baseUrl prefixes the request - **WHEN** the provider is constructed with `baseUrl: "https://sandbox-api.coinmarketcap.com/v1"` - **THEN** the request URL starts with `https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest`. #### Scenario: Omitted fetch falls back to globalThis.fetch - **WHEN** the provider is constructed without a `fetch` field and `globalThis.fetch` is temporarily replaced with a recording stub - **THEN** the recording stub receives the call. **Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"honours a custom baseUrl"` AND `src/coinmarketcap/coinmarketcap.test.ts::"falls back to globalThis.fetch when fetch is not injected"` --- ### Requirement: Universal-tier purity and packaging The package SHALL be universal-tier with `sideEffects: false`, no `node:` / `bun:` / DOM imports in production source, explicit named barrels (no `export *` in non-root `index.ts`), test files importing runner primitives only from `@suaveplan/testing/runner`, and 100/100/100/100 istanbul coverage thresholds. Public subpath exports SHALL include `.` and `./coinmarketcap/coinmarketcap`. #### Scenario: Subpath exports tree-shake the provider class - **WHEN** a consumer imports `from "@suaveplan/currency-provider-coinmarketcap/coinmarketcap/coinmarketcap"` - **THEN** the import resolves to the provider module and the rest of the package tree-shakes away. **Verified by:** `package.json::exports` AND the §13 pre-ship gate (`bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-coinmarketcap` + `bunx turbo test:coverage`).