148 lines
6.0 KiB
Markdown
148 lines
6.0 KiB
Markdown
# Design: @suaveplan/currency-provider-kraken
|
|
|
|
> Non-normative. The normative requirements live in
|
|
> `./specs/currency-provider-kraken/spec.md`. The cross-cutting
|
|
> foundation-with-sibling-plugins architecture lives in
|
|
> `openspec/archive/2026-05-17-slim-currency-providers/design.md` — read it
|
|
> first; this document covers only the Kraken-specific deltas.
|
|
|
|
## Shape
|
|
|
|
```text
|
|
@suaveplan/currency-provider-kraken
|
|
├── src/symbols/symbols.ts # KRAKEN_SYMBOL_MAP + pair→symbol reverse
|
|
├── src/provider/provider.ts # KrakenProvider class + DEFAULT_KRAKEN_BASE_URL
|
|
└── src/index.ts # explicit named barrel
|
|
```
|
|
|
|
Two feature modules. Each carries a sibling `.md`, a `.test.ts`, and a
|
|
feature-folder `index.ts` (named-exports only — `export *` is banned per
|
|
AGENTS.md §6a).
|
|
|
|
## Wire shape
|
|
|
|
The Kraken public ticker returns:
|
|
|
|
```json
|
|
{
|
|
"error": ["EGeneral:Too many requests"],
|
|
"result": {
|
|
"XXBTZUSD": { "c": ["67000.00", "0.1"], "..." },
|
|
"XETHZUSD": { "c": ["3500.00", "1.0"], "..." }
|
|
}
|
|
}
|
|
```
|
|
|
|
- `error` is **always** present (empty array on success). A non-empty array
|
|
is the canonical upstream-failure signal and maps to
|
|
`CurrencyProviderError(UPSTREAM_ERROR)`. The first entry is carried on
|
|
`upstreamMessage` so consumers can dispatch on rate-limit / endpoint /
|
|
pair-unknown messages.
|
|
- `result` is keyed by Kraken's non-standard pair name (`XXBTZUSD`, not
|
|
`BTCUSD`). The symbol map normalises both directions; the reverse map
|
|
lets us go from the wire pair back to a Suaveplan symbol in O(1).
|
|
- `c` is `[price, lotVolume]` — `c[0]` is the last-trade-closed price.
|
|
|
|
## Symbol mapping
|
|
|
|
Twelve symbols are mapped, matching the upstream coverage. The list is
|
|
intentionally small (rather than dynamic) because:
|
|
|
|
- Kraken's full pair list rotates frequently (new listings, delistings) and
|
|
pulling the live `AssetPairs` list per request would double the round trips.
|
|
- The mapped set is the high-volume subset most consumers actually want.
|
|
- Callers who need an additional symbol can supply a custom symbol-to-pair
|
|
override via the constructor (`config.extraSymbols`) without forking the
|
|
package. The override map is merged into `KRAKEN_SYMBOL_MAP` at
|
|
construction time.
|
|
|
|
```ts
|
|
const KRAKEN_SYMBOL_MAP: Readonly<Record<string, string>> = Object.freeze({
|
|
BTC: "XXBTZUSD",
|
|
ETH: "XETHZUSD",
|
|
SOL: "SOLUSD",
|
|
USDT: "USDTZUSD",
|
|
DOT: "DOTUSD",
|
|
ADA: "ADAUSD",
|
|
LINK: "LINKUSD",
|
|
AVAX: "AVAXUSD",
|
|
ATOM: "ATOMUSD",
|
|
LTC: "XLTCZUSD",
|
|
DOGE: "XDGUSD",
|
|
XRP: "XXRPZUSD",
|
|
});
|
|
```
|
|
|
|
## Rate model
|
|
|
|
Kraken returns the USD price of one unit of the crypto (e.g. `1 BTC = 67000
|
|
USD`). The `CurrencyProvider` contract returns rates as "1 base = N target"
|
|
where base defaults to `USD`. The rate for `USD → BTC` is therefore the
|
|
reciprocal: `1 / 67000`. This matches the upstream behaviour verbatim and is
|
|
what every downstream `CurrencyConverter` assumes.
|
|
|
|
If `c[0]` does not parse to a positive finite number, the entry is dropped
|
|
(skipped) rather than raising — Kraken occasionally returns `"0.00"` for
|
|
freshly-listed pairs that haven't yet traded. If the entire mapped set drops,
|
|
the provider raises `UNKNOWN_CURRENCY` so the caller sees the failure rather
|
|
than a silent empty rate map.
|
|
|
|
## Targets filtering
|
|
|
|
When `options.targets` is non-empty, the provider filters to those targets
|
|
after building the full rate map. Any target that is not in
|
|
`KRAKEN_SYMBOL_MAP` is a no-op (it simply doesn't appear in the result). If
|
|
the filtered set is empty AND `options.targets` was non-empty, the provider
|
|
raises `UNKNOWN_CURRENCY` — this is the "the caller asked for X / Y / Z and
|
|
Kraken can't price any of them" signal, distinct from "Kraken is down"
|
|
(`HTTP_ERROR`), "Kraken returned an error array" (`UPSTREAM_ERROR`), and
|
|
"the response body is malformed" (`PARSE_ERROR`).
|
|
|
|
## Error path matrix
|
|
|
|
| Wire condition | Code | `providerName` | Other discriminants |
|
|
|---|---|---|---|
|
|
| Non-2xx response | `HTTP_ERROR` | `"kraken"` | `status` |
|
|
| `data.error.length > 0` | `UPSTREAM_ERROR` | `"kraken"` | `upstreamMessage = data.error[0]` |
|
|
| JSON parse failure | `PARSE_ERROR` | `"kraken"` | (cause: original error) |
|
|
| `data.result` not an object | `PARSE_ERROR` | `"kraken"` | (cause: shape-check failure) |
|
|
| All requested targets unmapped | `UNKNOWN_CURRENCY` | `"kraken"` | — |
|
|
|
|
`HTTP_ERROR` is raised by the shared `httpRequest()` helper (we don't
|
|
re-implement it); the other three are raised inline after the response body
|
|
is read.
|
|
|
|
## Test strategy
|
|
|
|
- `symbols.test.ts` — asserts the 12-symbol map is exported, immutable
|
|
(`Object.isFrozen`), and that the reverse map is consistent.
|
|
- `provider.test.ts` — uses a recording `FetchLike` closure (the same
|
|
pattern the slim core's `http.test.ts` established). Covers:
|
|
- happy path with a real-shape Kraken response (`error: []`, `result: {…}`)
|
|
- `supportsCrypto` / `supportsFiat` metadata
|
|
- rates are reciprocals of `c[0]`
|
|
- `options.base` default of `"USD"`
|
|
- `options.targets` filtering (subset)
|
|
- `options.targets` filtering with one unmapped → still returns mapped ones
|
|
- `options.targets` all unmapped → `UNKNOWN_CURRENCY`
|
|
- `data.error` non-empty → `UPSTREAM_ERROR` with `upstreamMessage`
|
|
- non-2xx response → `HTTP_ERROR` with `status` (via the shared helper)
|
|
- JSON-parse failure → `PARSE_ERROR`
|
|
- shape-check failure (missing `result`) → `PARSE_ERROR`
|
|
- `c[0]` non-positive / non-finite → entry skipped, others retained
|
|
- custom `baseUrl` is honoured
|
|
- custom `fetch` (FetchLike) is used in place of `globalThis.fetch`
|
|
- `options.signal` is forwarded into the `httpRequest` call (abort
|
|
propagates)
|
|
|
|
All tests import runner primitives from `@suaveplan/testing/runner`.
|
|
No `vi.mock`, no `vi.stubGlobal` — only injection through the constructor.
|
|
|
|
## Tier purity
|
|
|
|
Universal-tier. The Kraken endpoint is reachable from any runtime that has
|
|
`fetch`: Node ≥ 18, Bun, modern browsers (subject to Kraken's CORS policy
|
|
— at time of writing the public ticker endpoint serves
|
|
`Access-Control-Allow-Origin: *`), Web Workers. No `node:`/`bun:`/DOM
|
|
imports.
|