152 lines
6.6 KiB
Markdown
152 lines
6.6 KiB
Markdown
# 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
|
||
|
||
```text
|
||
@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:
|
||
|
||
```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.
|