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

152 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.