Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.