Initial Commit
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user