Initial Commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# Add @suaveplan/currency-provider-coingecko
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-coingecko`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new package (sibling-plugin landing from the
|
||||
2026-05-17 `slim-currency-providers` refactor wave)
|
||||
|
||||
- **Spec delta**: ./specs/currency-provider-coingecko/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/currency-providers` was slimmed on 2026-05-17 to a thin foundation
|
||||
shipping the `CurrencyProvider` contract, `CurrencyProviderRegistry`,
|
||||
`defineCurrencyProvider` factory, shared `httpRequest` + `composeAbortSignal`
|
||||
HTTP helper, the `ApiKeyConfig` / `NoKeyConfig` config shapes, and the typed
|
||||
`CurrencyProviderError` + `CURRENCY_PROVIDER_CODES` registry. The nine
|
||||
concrete providers (ECB, Fixer, OXR, Binance, CoinGecko, CoinMarketCap,
|
||||
CryptoCompare, Kraken, Virtual) moved to sibling
|
||||
`@suaveplan/currency-provider-<name>` packages so consumers install only what
|
||||
they wire in and each provider iterates on its own version cadence.
|
||||
|
||||
This change lands the CoinGecko sibling. CoinGecko exposes a free public
|
||||
endpoint (no key, rate-limited at 10–30 calls/min) and a paid Pro tier that
|
||||
accepts the API key via an `x-cg-pro-api-key` header. Both shapes share the
|
||||
same request URL pattern (`/simple/price?ids=...&vs_currencies=...`) and the
|
||||
same response shape, so the key is genuinely optional rather than mutually
|
||||
exclusive — consumers upgrade by passing `apiKey` without otherwise changing
|
||||
their wiring.
|
||||
|
||||
## What changes
|
||||
|
||||
### ADDED
|
||||
|
||||
- `CoinGeckoProvider` class implementing
|
||||
`CurrencyProvider from @suaveplan/currency` — `name = "coingecko"`,
|
||||
`supportsCrypto = true`, `supportsFiat = true`. Constructor accepts an
|
||||
optional config object of shape
|
||||
`NoKeyConfig & { readonly apiKey?: string }`; the `apiKey` field is
|
||||
truly optional (free tier requires nothing).
|
||||
- `fetchRates(options?)` issues a single request through the shared
|
||||
`httpRequest()` helper, attaches the `x-cg-pro-api-key` header iff
|
||||
`apiKey` was supplied, parses the JSON body, inverts the upstream
|
||||
price ("1 crypto = X fiat") into the converter's rate convention
|
||||
("1 base = X crypto"), and filters to `options.targets` when given.
|
||||
- `getCoinGeckoId(symbol)` lookup helper exposing the curated symbol →
|
||||
CoinGecko-ID mapping (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA, DOGE,
|
||||
DOT, AVAX, MATIC, LINK, UNI, ATOM, LTC). Case-insensitive; returns
|
||||
`undefined` for unknown symbols.
|
||||
- Failure paths raise `CurrencyProviderError`:
|
||||
- `HTTP_ERROR` — non-2xx response (delegated to `httpRequest()`).
|
||||
- `PARSE_ERROR` — response body is not valid JSON.
|
||||
- `UPSTREAM_ERROR` — JSON parses but is not the expected
|
||||
`Record<string, Record<string, number>>` shape.
|
||||
- `UNKNOWN_CURRENCY` — caller-supplied `options.targets` includes a
|
||||
symbol with no CoinGecko-ID mapping; raised proactively before the
|
||||
HTTP call so the diagnostic surfaces the bad symbol rather than a
|
||||
silent empty `rates` field.
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Migrated module | Upstream source |
|
||||
|---|---|
|
||||
| `CoinGeckoProvider`, `getCoinGeckoId` | `../suaveplan/packages/finance/currency-providers/src/crypto/coingecko.ts` |
|
||||
|
||||
Upstream used a bare `fetch` call, bare `Error`, and no key support. This
|
||||
migration upgrades all three: routes through the shared `httpRequest`
|
||||
helper (so abort-signal composition + per-call timeouts + typed
|
||||
`HTTP_ERROR` come for free), raises typed `CurrencyProviderError`s with
|
||||
stable codes (so consumers can dispatch on `err.code`), and adds the
|
||||
optional `apiKey` field with `x-cg-pro-api-key` header attachment for
|
||||
the Pro tier.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-coingecko` to
|
||||
enumerate. As this is a new package, the dependent list is empty at
|
||||
landing.
|
||||
|
||||
- No current dependents (new package; verified by
|
||||
`bunx turbo ls --filter=...@suaveplan/currency-provider-coingecko`).
|
||||
|
||||
The package consumes (does not modify):
|
||||
|
||||
- `@suaveplan/currency` — `CurrencyProvider`, `RateFetchOptions`,
|
||||
`RateFetchResult` contract types only. [x] no action needed (contract
|
||||
consumer, not modifier).
|
||||
- `@suaveplan/currency-providers` — `httpRequest`, `composeAbortSignal`,
|
||||
`CurrencyProviderError`, `CURRENCY_PROVIDER_CODES`, `NoKeyConfig`.
|
||||
[x] no action needed (slim core is the surface this package is built
|
||||
against).
|
||||
- `@suaveplan/types` — `FetchLike`. [x] no action needed.
|
||||
- `@suaveplan/error` — transitively via `CurrencyProviderError`'s base
|
||||
class. [x] no action needed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Historical-rate queries (`/coins/{id}/history`), trending coins, market
|
||||
cap data — `CurrencyProvider` only contracts for spot rates.
|
||||
- WebSocket / push subscriptions — CoinGecko does not offer a public
|
||||
WebSocket; sibling providers that do (Binance, Kraken) own that surface.
|
||||
- Auto-discovery of CoinGecko IDs beyond the curated 16-entry map —
|
||||
consumers needing a different symbol register it via their own wrapper
|
||||
or open a follow-up change extending the map.
|
||||
- Rate-limit awareness, retry, OTel instrumentation — same deferral as
|
||||
the slim core and every other sibling provider; once those wrappers
|
||||
land they apply uniformly via the registry.
|
||||
@@ -0,0 +1,187 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: currency-provider-coingecko (add-currency-provider-coingecko)
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-coingecko`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
> No exceptions.
|
||||
|
||||
## ADDED 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"`
|
||||
@@ -0,0 +1,63 @@
|
||||
# Tasks — Add @suaveplan/currency-provider-coingecko
|
||||
|
||||
## Spec-completeness preflight (AGENTS.md §12a)
|
||||
|
||||
- [x] Numbered checklist of every `### Requirement` and `#### Scenario`
|
||||
drafted in `specs/currency-provider-coingecko/spec.md` (7
|
||||
requirements added; see the spec delta).
|
||||
|
||||
## Package scaffolding
|
||||
|
||||
- [x] Create `packages/finance/currency-provider-coingecko/` directory tree
|
||||
- [x] `package.json` — `0.1.0`, universal-tier, `sideEffects: false`,
|
||||
subpath exports for the feature modules, deps via `workspace:^` and
|
||||
`catalog:`
|
||||
- [x] `tsconfig.json`, `tsconfig.lib.json`
|
||||
- [x] `vite.config.ts` (build), `vitest.config.ts` (test + 100/100/100/100)
|
||||
- [x] `tests/setup/polyfills.ts`
|
||||
- [x] `README.md`
|
||||
|
||||
## Implementation
|
||||
|
||||
- [x] `src/coin-ids/coin-ids.ts` — curated symbol → CoinGecko-ID map +
|
||||
`getCoinGeckoId()` lookup
|
||||
- [x] `src/coin-ids/coin-ids.md` — sibling doc, ≥200 words
|
||||
- [x] `src/coin-ids/coin-ids.test.ts` — full coverage of the lookup
|
||||
- [x] `src/coin-ids/index.ts` — explicit named barrel
|
||||
- [x] `src/coingecko/coingecko.ts` — `CoinGeckoProvider` class
|
||||
- [x] `src/coingecko/coingecko.md` — sibling doc, ≥200 words
|
||||
- [x] `src/coingecko/coingecko.test.ts` — keyless + Pro-key branches +
|
||||
every `CurrencyProviderError` code path
|
||||
- [x] `src/coingecko/index.ts` — explicit named barrel
|
||||
- [x] `src/index.ts` — explicit named root barrel
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-coingecko`
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/currency-provider-coingecko`
|
||||
at 100/100/100/100
|
||||
- [x] `bunx turbo build --filter=@suaveplan/currency-provider-coingecko`
|
||||
- [x] Banned-pattern scan zero
|
||||
- [x] Barrel-shape scan zero (no `export *` in feature barrels)
|
||||
- [x] Test-runner import scan zero (every test goes through
|
||||
`@suaveplan/testing/runner`)
|
||||
- [x] Missing `.md` scan zero
|
||||
- [x] `README.md` present
|
||||
|
||||
## Repo-doc refresh (AGENTS.md §13b)
|
||||
|
||||
- [x] Add `currency-provider-coingecko` to
|
||||
`scripts/folder-reorg/assignment.yml` under the finance group
|
||||
- [x] `bun scripts/folder-reorg/generate-manifest.ts` → refresh
|
||||
`MANIFEST.md`
|
||||
- [x] `bun scripts/generate-missing.ts` → refresh `MISSING.yml`
|
||||
- [x] Update `STATUS.md` with a row for the new package
|
||||
- [x] Update `SUMMARY.md` per-package paragraph
|
||||
- [x] `bunx markdownlint-cli2 --fix` clean
|
||||
|
||||
## Archive
|
||||
|
||||
- [x] Fold `specs/currency-provider-coingecko/spec.md` into
|
||||
`openspec/specs/currency-provider-coingecko/spec.md`
|
||||
- [x] Move `openspec/changes/add-currency-provider-coingecko/` →
|
||||
`openspec/archive/2026-05-17-add-currency-provider-coingecko/`
|
||||
Reference in New Issue
Block a user