Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: currency-provider-kraken
**Package**: `@suaveplan/currency-provider-kraken`
**Status**: ACCEPTED — landed by `openspec/archive/2026-05-17-add-currency-provider-kraken/`.
> Every requirement below carries a `**Verified by:**` line.
## Requirements
### Requirement: KrakenProvider implements the CurrencyProvider contract
The package SHALL export a `KrakenProvider` class implementing the
`CurrencyProvider` contract from `@suaveplan/currency`. Instances MUST
expose `name = "kraken"`, `supportsCrypto = true`, `supportsFiat = false`,
and a `fetchRates(options?)` method returning a `Promise<RateFetchResult>`.
#### Scenario: identity metadata is fixed
- **WHEN** a new `KrakenProvider()` is constructed
- **THEN** `provider.name === "kraken"`, `provider.supportsCrypto === true`,
and `provider.supportsFiat === false`
**Verified by:** `src/provider/provider.test.ts::"exposes the canonical kraken identity"`
---
### Requirement: Constructor accepts NoKeyConfig with optional extras
The constructor SHALL accept an optional `KrakenProviderConfig` extending
the slim core's `NoKeyConfig` (optional `baseUrl`, optional injectable
`FetchLike`). It MUST additionally accept an optional `extraSymbols` record
that is merged on top of `KRAKEN_SYMBOL_MAP` so callers can extend coverage
without forking the package. The constructor SHALL NOT require an API key.
#### Scenario: defaults are used when no config is supplied
- **WHEN** `new KrakenProvider()` is constructed and `fetchRates()` is called
- **THEN** the request URL begins with `DEFAULT_KRAKEN_BASE_URL` and the
injected `globalThis.fetch` is used
#### Scenario: custom baseUrl and fetch are honoured
- **WHEN** `new KrakenProvider({ baseUrl: "https://custom.kraken", fetch: recordingFetch })` is constructed and `fetchRates()` is called
- **THEN** `recordingFetch` is invoked exactly once with a URL beginning with `"https://custom.kraken"`
#### Scenario: extraSymbols extends the symbol map
- **WHEN** `new KrakenProvider({ extraSymbols: { MATIC: "MATICUSD" } })` is constructed and the Kraken response carries `MATICUSD`
- **THEN** the returned `rates` map contains a `MATIC` entry
**Verified by:** `src/provider/provider.test.ts::"defaults to globalThis.fetch and DEFAULT_KRAKEN_BASE_URL"` AND `src/provider/provider.test.ts::"honours custom baseUrl and fetch"` AND `src/provider/provider.test.ts::"extends the symbol map via extraSymbols"`
---
### Requirement: Uses the shared httpRequest helper
The provider SHALL issue its request through `httpRequest()` from
`@suaveplan/currency-providers/http/http` so that abort-signal composition,
timeout derivation, and non-2xx → `HTTP_ERROR` mapping are uniform across
every sibling provider. It SHALL forward `options.signal` to the helper.
#### Scenario: non-2xx response raises HTTP_ERROR
- **WHEN** the injected fetch responds with status 503
- **THEN** `fetchRates()` rejects with a `CurrencyProviderError` whose
`code === CURRENCY_PROVIDER_CODES.HTTP_ERROR`, `providerName === "kraken"`,
and `status === 503`
#### Scenario: caller signal aborts the request
- **WHEN** `options.signal` is an already-aborted `AbortSignal`
- **THEN** `fetchRates({ signal })` rejects with an abort error (the
injected fetch sees an aborted signal)
**Verified by:** `src/provider/provider.test.ts::"raises HTTP_ERROR on non-2xx response"` AND `src/provider/provider.test.ts::"forwards options.signal to the underlying fetch"`
---
### Requirement: Maps Kraken error array to UPSTREAM_ERROR
When the Kraken response body's `error` field is a non-empty string array,
the provider SHALL raise `CurrencyProviderError(UPSTREAM_ERROR)` with
`providerName: "kraken"` and `upstreamMessage` set to the first element of
the array. The error message string SHALL include the first upstream
message so existing `Error.message` consumers continue to match it.
#### Scenario: non-empty error array maps to UPSTREAM_ERROR
- **WHEN** the response body is `{ error: ["EGeneral:Too many requests"], result: {} }`
- **THEN** `fetchRates()` rejects with a `CurrencyProviderError` whose
`code === CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR`,
`upstreamMessage === "EGeneral:Too many requests"`,
and `message` contains `"Too many requests"`
**Verified by:** `src/provider/provider.test.ts::"maps a non-empty error array to UPSTREAM_ERROR"`
---
### Requirement: Maps unparsable bodies to PARSE_ERROR
When the response body is not valid JSON, or when the parsed body's
`result` field is missing / not an object, the provider SHALL raise
`CurrencyProviderError(PARSE_ERROR)` with `providerName: "kraken"`.
#### Scenario: malformed JSON raises PARSE_ERROR
- **WHEN** the response body is `"not-json"`
- **THEN** `fetchRates()` rejects with a `CurrencyProviderError` whose
`code === CURRENCY_PROVIDER_CODES.PARSE_ERROR`
#### Scenario: missing result field raises PARSE_ERROR
- **WHEN** the response body is `{ "error": [] }` (no `result`)
- **THEN** `fetchRates()` rejects with a `CurrencyProviderError` whose
`code === CURRENCY_PROVIDER_CODES.PARSE_ERROR`
**Verified by:** `src/provider/provider.test.ts::"raises PARSE_ERROR on malformed JSON"` AND `src/provider/provider.test.ts::"raises PARSE_ERROR when result is missing"`
---
### Requirement: Maps ticker prices to reciprocal-of-USD rates
For every entry in `data.result` whose Kraken pair matches a mapped symbol,
the provider SHALL set `rates[symbol] = 1 / Number.parseFloat(ticker.c[0])`
when that float is positive and finite. Entries whose price does not parse
to a positive finite number SHALL be skipped (not raised) — Kraken
occasionally returns `"0.00"` for freshly-listed pairs.
#### Scenario: positive prices are reciprocated
- **WHEN** the response carries `XXBTZUSD: { c: ["67000.00", "0.1"] }` and `XETHZUSD: { c: ["3500.00", "1.0"] }`
- **THEN** the returned rates satisfy `rates.BTC ≈ 1/67000` and `rates.ETH ≈ 1/3500`
#### Scenario: zero / non-finite prices are skipped, not raised
- **WHEN** the response carries `XXBTZUSD: { c: ["0.00", "0"] }` alongside a valid `XETHZUSD` entry
- **THEN** the returned rates contain `ETH` but not `BTC`, and no error is raised
**Verified by:** `src/provider/provider.test.ts::"maps positive prices to reciprocal-of-USD rates"` AND `src/provider/provider.test.ts::"skips zero or non-finite prices without raising"`
---
### Requirement: Filters by options.targets and raises on all-unknown targets
When `options.targets` is supplied, the provider SHALL return only rates for
the requested target symbols. If after filtering the resulting rate map is
empty AND `options.targets` was non-empty, the provider SHALL raise
`CurrencyProviderError(UNKNOWN_CURRENCY)` with `providerName: "kraken"` so
callers see the failure rather than a silent empty rate set.
#### Scenario: subset filtering returns only requested targets
- **WHEN** the response carries BTC + ETH + SOL and `fetchRates({ targets: ["BTC"] })` is called
- **THEN** the returned `rates` has exactly one key — `"BTC"`
#### Scenario: all-unknown targets raise UNKNOWN_CURRENCY
- **WHEN** `fetchRates({ targets: ["FAKE1", "FAKE2"] })` is called against a healthy ticker response
- **THEN** `fetchRates` rejects with a `CurrencyProviderError` whose
`code === CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` and
`providerName === "kraken"`
**Verified by:** `src/provider/provider.test.ts::"filters by options.targets"` AND `src/provider/provider.test.ts::"raises UNKNOWN_CURRENCY when every requested target is unmapped"`
---
### Requirement: Exposes the symbol-mapping surface
The package SHALL export `KRAKEN_SYMBOL_MAP` (the canonical symbol → Kraken
pair record), `KRAKEN_PAIR_TO_SYMBOL` (the reverse map, pair → symbol), and
`DEFAULT_KRAKEN_BASE_URL`. The forward map SHALL be `Object.freeze`-d.
#### Scenario: symbol map is exported, frozen, and complete
- **WHEN** the package is imported
- **THEN** `KRAKEN_SYMBOL_MAP` is exported, `Object.isFrozen(KRAKEN_SYMBOL_MAP)` is `true`, and it contains entries for `BTC`, `ETH`, `SOL`, `USDT`, `DOT`, `ADA`, `LINK`, `AVAX`, `ATOM`, `LTC`, `DOGE`, `XRP`
#### Scenario: reverse map is consistent with the forward map
- **WHEN** any entry `KRAKEN_SYMBOL_MAP[symbol] === pair` is examined
- **THEN** `KRAKEN_PAIR_TO_SYMBOL[pair] === symbol`
**Verified by:** `src/symbols/symbols.test.ts::"exports a frozen 12-entry symbol map"` AND `src/symbols/symbols.test.ts::"reverse map is consistent with the forward map"`