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

8.3 KiB

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=<curated-ids>&vs_currencies=<base-lower> 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: <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<string, Record<string, number>> 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"