# 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-` sibling shares. This file only > records the Binance-specific decisions on top of that shape. ## Shape ```text @suaveplan/currency-provider-binance └── BinanceProvider implements CurrencyProvider ├── name === "binance" ├── supportsCrypto === true ├── supportsFiat === false └── fetchRates(options) └── httpRequest({ url: "/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: ```ts // 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 ```text 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.