Files
suaveplan/openspec/archive/2026-05-17-add-currency-provider-kraken/design.md
2026-05-27 23:29:53 +01:00

6.0 KiB

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

@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:

{
  "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.
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.