6.6 KiB
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 byopenspec/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:
- Length guard — a symbol equal to the suffix decodes to an empty
string. Upstream produced
rates[""] = 1 / pricefor the rareUSDTUSDT-style edge case; we skip these explicitly. - Finite-and-positive price —
Number.parseFloatreturnsNaNfor the literal"NaN"or any malformed numeric string Binance occasionally emits for delisted-but-still-published symbols.Number.isFinite(price) && price > 0is 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
fetchvia injectedFetchLike(defaultglobalThis.fetch) Response.json(),Number.parseFloat,Number.isFinite,Date.now(),String.prototype.endsWith/.slice- No
node:, nobun:, no DOM-specific APIs
Runs unchanged under Node 18+, Bun, modern browsers, and Web Workers.
Test strategy
- Recording
FetchLikeclosure (novi.mock, novi.spyOnon globalfetchexcept 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 typedCurrencyProviderError.codefield. - 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/runnerper §7.