# Capability Spec: currency-provider-coingecko **Package**: `@suaveplan/currency-provider-coingecko` **Status**: SHIPPED (2026-05-17 — `add-currency-provider-coingecko`) > Folded from `openspec/changes/add-currency-provider-coingecko/specs/currency-provider-coingecko/spec.md` > on 2026-05-17. ## Requirements ### Requirement: CoinGeckoProvider implements the CurrencyProvider contract The package SHALL export `CoinGeckoProvider`, a class implementing the `CurrencyProvider` contract from `@suaveplan/currency`. It SHALL expose the static identity fields `name = "coingecko"`, `supportsCrypto = true`, and `supportsFiat = true`. The constructor SHALL accept an optional configuration object of shape `NoKeyConfig & { readonly apiKey?: string }`; omitting the config (free public tier) SHALL be a valid call. #### Scenario: provider declares stable metadata at construction - **WHEN** a caller instantiates `new CoinGeckoProvider()` with no config - **THEN** the returned instance carries `name === "coingecko"`, `supportsCrypto === true`, and `supportsFiat === true` **Verified by:** `src/coingecko/coingecko.test.ts::"exposes stable name and supports flags"` --- ### Requirement: fetchRates uses the shared httpRequest helper with default base URL `fetchRates(options?)` SHALL build the request URL from the configured base URL (default `https://api.coingecko.com/api/v3`), append `/simple/price?ids=&vs_currencies=` query parameters, and issue the request through `httpRequest()` from `@suaveplan/currency-providers`. The injected `FetchLike` from `ProviderConfigBase.fetch` (default `globalThis.fetch`) SHALL be used. The caller's `RateFetchOptions.signal` SHALL be forwarded to `httpRequest` so abort-signal composition propagates. #### Scenario: keyless call hits the default endpoint with vs_currencies derived from base - **WHEN** `new CoinGeckoProvider().fetchRates({ base: "USD" })` runs with an injected `FetchLike` that records the request URL - **THEN** the recorded URL SHALL begin with `https://api.coingecko.com/api/v3/simple/price?ids=` and SHALL end with `&vs_currencies=usd` #### Scenario: custom baseUrl overrides the default - **WHEN** `new CoinGeckoProvider({ baseUrl: "https://custom.example/v3", fetch }).fetchRates({ base: "EUR" })` runs - **THEN** the recorded URL SHALL begin with `https://custom.example/v3/simple/price` **Verified by:** `src/coingecko/coingecko.test.ts::"issues request to default endpoint with vs_currencies from base"` AND `src/coingecko/coingecko.test.ts::"honours custom baseUrl override"` --- ### Requirement: Pro-tier API key is attached as x-cg-pro-api-key header When the constructor receives `apiKey`, every outbound request SHALL carry the header `x-cg-pro-api-key: `. When `apiKey` is absent, the provider SHALL NOT attach the header (keeping the request shape indistinguishable from a public-tier call). #### Scenario: Pro-tier configuration attaches the header - **WHEN** `new CoinGeckoProvider({ apiKey: "secret", fetch }).fetchRates()` runs with a recording `FetchLike` - **THEN** the recorded `init.headers` SHALL contain `"x-cg-pro-api-key": "secret"` #### Scenario: free-tier configuration omits the header - **WHEN** `new CoinGeckoProvider({ fetch }).fetchRates()` runs with a recording `FetchLike` - **THEN** the recorded `init.headers` SHALL NOT contain a `x-cg-pro-api-key` entry (the entire `headers` field SHALL be absent or empty) **Verified by:** `src/coingecko/coingecko.test.ts::"attaches x-cg-pro-api-key header when apiKey is provided"` AND `src/coingecko/coingecko.test.ts::"omits x-cg-pro-api-key header when apiKey is absent"` --- ### Requirement: Successful responses invert prices into the converter rate convention CoinGecko returns prices as "1 crypto = X fiat"; the package SHALL invert those into "1 base = X crypto" so they match the `RateFetchResult` convention every other provider uses. The result SHALL set `base` to the caller-supplied base (default `"USD"`), `timestamp` to `Date.now()` at parse time, and `rates` to a record keyed by uppercase crypto symbol with inverted numeric values. When `options.targets` is supplied, the result SHALL include only the requested symbols. #### Scenario: rates are inverted from upstream price - **WHEN** the upstream returns `{ bitcoin: { usd: 67000 }, ethereum: { usd: 3500 } }` and `fetchRates({ base: "USD" })` runs - **THEN** the returned `rates.BTC` SHALL equal `1 / 67000` and `rates.ETH` SHALL equal `1 / 3500` #### Scenario: target filtering narrows the result set - **WHEN** the upstream returns rates for multiple coins and `fetchRates({ base: "USD", targets: ["BTC", "ETH"] })` runs - **THEN** `Object.keys(result.rates)` SHALL be exactly `["BTC", "ETH"]` (in some order) and SHALL NOT include any other coin **Verified by:** `src/coingecko/coingecko.test.ts::"inverts upstream prices into base→crypto rates"` AND `src/coingecko/coingecko.test.ts::"filters rates by options.targets"` --- ### Requirement: HTTP failures raise CurrencyProviderError(HTTP_ERROR) Non-2xx responses SHALL surface as `CurrencyProviderError(CURRENCY_PROVIDER_CODES.HTTP_ERROR)` with `providerName === "coingecko"` and `status` populated from the upstream response. The raise SHALL be performed by the shared `httpRequest()` helper (this package does not re-implement the check). #### Scenario: 429 from upstream surfaces as HTTP_ERROR with status 429 - **WHEN** the injected `FetchLike` returns a `Response` with status `429` - **THEN** `fetchRates()` SHALL reject with a `CurrencyProviderError` whose `code === CURRENCY_PROVIDER_CODES.HTTP_ERROR`, `providerName === "coingecko"`, and `status === 429` **Verified by:** `src/coingecko/coingecko.test.ts::"raises HTTP_ERROR on non-2xx response"` --- ### Requirement: Malformed responses raise PARSE_ERROR or UPSTREAM_ERROR When the response body is not valid JSON, `fetchRates()` SHALL raise `CurrencyProviderError(PARSE_ERROR)`. When the body parses as JSON but is not the expected `Record>` shape (e.g. upstream returns an array or `null` or any non-object), `fetchRates()` SHALL raise `CurrencyProviderError(UPSTREAM_ERROR)`. Both errors SHALL carry `providerName === "coingecko"`. #### Scenario: invalid JSON surfaces as PARSE_ERROR - **WHEN** the upstream returns a 200 response with body `"not json"` - **THEN** `fetchRates()` SHALL reject with `code === CURRENCY_PROVIDER_CODES.PARSE_ERROR` and `providerName === "coingecko"` #### Scenario: wrong-shape JSON surfaces as UPSTREAM_ERROR - **WHEN** the upstream returns a 200 response with body `"[]"` (a JSON array — wrong shape) - **THEN** `fetchRates()` SHALL reject with `code === CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR` and `providerName === "coingecko"` **Verified by:** `src/coingecko/coingecko.test.ts::"raises PARSE_ERROR when body is not valid JSON"` AND `src/coingecko/coingecko.test.ts::"raises UPSTREAM_ERROR when body is the wrong shape"` --- ### Requirement: getCoinGeckoId exposes a case-insensitive symbol lookup The package SHALL export `getCoinGeckoId(symbol: string)` returning the CoinGecko ID for the curated symbol set (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA, DOGE, DOT, AVAX, MATIC, LINK, UNI, ATOM, LTC). The lookup SHALL be case-insensitive and SHALL return `undefined` for unknown symbols. When `fetchRates()` receives `options.targets` containing a symbol with no mapping, it SHALL raise `CurrencyProviderError(UNKNOWN_CURRENCY)` proactively (before issuing the HTTP request) carrying the offending symbol in the message. #### Scenario: known symbol resolves regardless of case - **WHEN** `getCoinGeckoId("btc")` and `getCoinGeckoId("BTC")` are called - **THEN** both SHALL return `"bitcoin"` #### Scenario: unknown target raises UNKNOWN_CURRENCY before any HTTP call - **WHEN** `fetchRates({ targets: ["BTC", "ZZZ"] })` runs with a recording `FetchLike` - **THEN** the call SHALL reject with `code === CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` AND the recording `FetchLike` SHALL NOT have been invoked **Verified by:** `src/coin-ids/coin-ids.test.ts::"returns the CoinGecko id for known symbols regardless of case"` AND `src/coin-ids/coin-ids.test.ts::"returns undefined for unknown symbols"` AND `src/coingecko/coingecko.test.ts::"raises UNKNOWN_CURRENCY when a target has no CoinGecko mapping"`