188 lines
8.3 KiB
Markdown
188 lines
8.3 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|