Files
2026-05-27 23:29:53 +01:00

6.6 KiB
Raw Permalink Blame History

Design — @suaveplan/currency-provider-binance

Non-normative. The normative requirements live in ./specs/currency-provider-binance/spec.md. This document explains the design choices that fall out of the cross-cutting foundation-with-sibling-plugins architecture established by openspec/archive/2026-05-17-slim-currency-providers/design.md — which is the single source of truth for the shape every @suaveplan/currency-provider-<name> sibling shares. This file only records the Binance-specific decisions on top of that shape.

Shape

@suaveplan/currency-provider-binance
└── BinanceProvider implements CurrencyProvider
    ├── name === "binance"
    ├── supportsCrypto === true
    ├── supportsFiat === false
    └── fetchRates(options)
        └── httpRequest({ url: "<base>/ticker/price", fetch, providerName, signal, timeoutMs })
            └── parses JSON → ensures shape → decodes 1/price → optionally filters targets

One feature module (src/binance/), one subpath export (./binance/binance), one class. No registry, no factory wiring — the slim core owns those concerns.

Why NoKeyConfig over a bespoke shape

Every sibling provider that wraps a key-less endpoint extends NoKeyConfig from the slim core. That gives consumers a uniform shape (baseUrl?, fetch?) across CoinGecko / CryptoCompare / Kraken / Binance and lets them swap providers without rewriting their wiring code. BinanceProvider's only addition is timeoutMs? — which is layered on as an intersection type rather than a new exported interface because (a) it is purely Binance-instance lifecycle config, not part of a RateFetchOptions-shaped per-call contract, and (b) naming a new interface for a single optional number field violates the §11 "no inline shapes" rule against accidental config proliferation.

Endpoint choice — /api/v3/ticker/price

Binance offers three relevant REST endpoints:

Endpoint Returns Why not chosen
/api/v3/ticker/price Latest spot mid-price for every symbol Chosen. Smallest payload, fastest, public, no params required.
/api/v3/ticker/24hr 24-hour rolling stats (open/high/low/close/volume) 10× the payload size; the converter only consumes the close price.
/api/v3/ticker/bookTicker Best bid/ask More accurate for execution but adds bid/ask spread the converter has no way to express.

Mid-price is the right semantic for a converter that returns "what is this currency worth" — execution-grade bid/ask belongs in a future trading-specific provider, not a generic rate source.

Suffix-match decoding

Binance symbols are concatenated without delimiters: BTCUSDT, ETHEUR, SOLBTC. To answer "what's the rate for BTC when base is USDT?" the provider walks every ticker, filters those whose symbol ends in the requested base, and slices the prefix as the asset code.

Two defensive checks beyond the upstream form:

  1. Length guard — a symbol equal to the suffix decodes to an empty string. Upstream produced rates[""] = 1 / price for the rare USDTUSDT-style edge case; we skip these explicitly.
  2. Finite-and-positive price — Number.parseFloat returns NaN for the literal "NaN" or any malformed numeric string Binance occasionally emits for delisted-but-still-published symbols. Number.isFinite(price) && price > 0 is the canonical "is this usable" filter.

Why UNKNOWN_CURRENCY on a missing target instead of silent drop

Upstream silently dropped missing targets from the result map:

// upstream
if (rates[t] !== undefined) {
    filtered[t] = rates[t]!;
}

That bug-compatibility decision means a consumer can't tell the difference between "Binance delisted SOL last week" and "the API was overloaded and returned an empty array". Both produce rates.SOL === undefined. The genesis form raises CurrencyProviderError(UNKNOWN_CURRENCY) with the target name in upstreamMessage, so consumers see the listing change immediately and can fall back to the next provider in their chain. This matches the slim core's design ethos: typed errors over silent missing data.

Why the failure paths split into four codes

The slim core's CURRENCY_PROVIDER_CODES registry already declares the four codes this provider raises. Reusing them keeps the consumer's error-dispatch logic uniform across every sibling:

Code Reached by
HTTP_ERROR Raised inside httpRequest for any non-2xx response.
PARSE_ERROR Raised when response.json() rejects (non-JSON body or stream error).
UPSTREAM_ERROR Raised when the JSON parses but the shape is not Array<{ symbol: string; price: string }>.
UNKNOWN_CURRENCY Raised when options.targets contains a key absent from the decoded rates map.

Per AGENTS.md §2c, all four codes are reachable from this package's src/ and exercised by tests — none are declared-but-unused.

File layout

src/
├── binance/
│   ├── binance.ts          # BinanceProvider + constants + helpers
│   ├── binance.md          # sibling doc (≥200 words, six required sections)
│   ├── binance.test.ts     # vitest tests via @suaveplan/testing/runner
│   └── index.ts            # explicit named barrel
└── index.ts                # explicit named barrel — three re-exports

One subpath export beyond the root: ./binance/binance. Five gates hit: the structure mirrors every other sibling so the §10 no-prefix rule, the §6a no-export * rule, the §7 runner-import rule, and the 100/100/100/100 coverage rule all transfer verbatim.

Tier purity

Universal-tier. The provider uses only:

  • WHATWG fetch via injected FetchLike (default globalThis.fetch)
  • Response.json(), Number.parseFloat, Number.isFinite, Date.now(), String.prototype.endsWith / .slice
  • No node:, no bun:, no DOM-specific APIs

Runs unchanged under Node 18+, Bun, modern browsers, and Web Workers.

Test strategy

  • Recording FetchLike closure (no vi.mock, no vi.spyOn on global fetch except a single test that proves the default-fetch fallback path works — that test does a manual swap-and-restore, not a mock library call).
  • Each failure code (HTTP_ERROR, PARSE_ERROR, UPSTREAM_ERROR, UNKNOWN_CURRENCY) has at least one test that asserts on the typed CurrencyProviderError.code field.
  • Branch coverage exercises every defensive guard: zero / negative / non-finite price skips, suffix-equals-symbol skip, malformed row (null, wrong type, missing field), abort propagation, timeout propagation, default-fetch fallback.
  • All tests import from @suaveplan/testing/runner per §7.