186 lines
8.3 KiB
Markdown
186 lines
8.3 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: currency-provider-kraken (add-currency-provider-kraken)
|
|
|
|
**Package**: `@suaveplan/currency-provider-kraken`
|
|
**Status**: PROPOSED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
|
|
|
## ADDED 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"`
|