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.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Add @suaveplan/currency-provider-binance — Binance public-ticker provider
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-binance`
|
||||
**Status**: ARCHIVED
|
||||
**Type**: feature (sibling provider in the slim currency-providers wave)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/currency-provider-binance/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-05-17 slim refactor of `@suaveplan/currency-providers` removed
|
||||
nine concrete provider classes from the umbrella package and committed
|
||||
to landing each as its own `@suaveplan/currency-provider-<name>`
|
||||
sibling. `BinanceProvider` is the Binance leg of that wave. It is the
|
||||
key-less crypto-rates provider in the line-up — alongside CoinGecko,
|
||||
CryptoCompare, and Kraken — and the only one that ships sub-second
|
||||
trading-desk pricing. Without it the slim core has no working crypto
|
||||
provider out of the box; downstream agents would have to hand-roll one,
|
||||
which is exactly the bug the slim refactor exists to fix.
|
||||
|
||||
This change ports the Binance implementation that previously lived at
|
||||
`packages/finance/currency-providers/src/crypto/binance.ts` upstream
|
||||
into the genesis monorepo as a standalone package, with three quality
|
||||
improvements over the upstream form:
|
||||
|
||||
1. HTTP transport is delegated to the slim core's `httpRequest` helper,
|
||||
so `HTTP_ERROR` carries the typed `CurrencyProviderError` shape
|
||||
instead of a plain `Error` and abort/timeout composition is uniform
|
||||
across every sibling.
|
||||
2. Failure paths are split into the four typed codes the slim spec
|
||||
defines (`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`,
|
||||
`UNKNOWN_CURRENCY`) — the upstream form raised a single plain
|
||||
`Error` on the HTTP path and silently dropped malformed payloads or
|
||||
missing targets.
|
||||
3. Payload shape is structurally validated (array of
|
||||
`{ symbol: string; price: string }`); upstream took the wire shape
|
||||
on faith and would `TypeError` on a Cloudflare error page.
|
||||
|
||||
## What changes
|
||||
|
||||
### ADDED
|
||||
|
||||
- `BinanceProvider` — class implementing the `CurrencyProvider`
|
||||
contract on top of the Binance public spot ticker (`GET
|
||||
/api/v3/ticker/price`).
|
||||
- `BINANCE_PROVIDER_NAME` (`"binance"`) and `BINANCE_DEFAULT_BASE_URL`
|
||||
(`"https://api.binance.com/api/v3"`) constants so consumers can wire
|
||||
the registry and overrides without string literals.
|
||||
|
||||
### NOT INCLUDED (out of scope — same deferrals as the slim core)
|
||||
|
||||
- WebSocket streaming (Binance `/ws/!ticker@arr`) — REST-only for v1
|
||||
- Historical klines / candlesticks
|
||||
- Authenticated endpoints (account, orders) — non-goal for a
|
||||
`CurrencyProvider`
|
||||
- Per-call rate-limit accounting — deferred to the future
|
||||
`RateLimitedProvider` wrapper from the slim core
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Module | Upstream source |
|
||||
|---|---|
|
||||
| `BinanceProvider` | `../suaveplan/packages/finance/currency-providers/src/crypto/binance.ts` |
|
||||
| Sibling-shape contract | `openspec/archive/2026-05-17-slim-currency-providers/design.md` |
|
||||
| Slim core surface consumed | `packages/finance/currency-providers/src/{http,error-codes,types}/` |
|
||||
|
||||
The new genesis-original wrappers (`httpRequest` delegation, typed
|
||||
`PARSE_ERROR` / `UPSTREAM_ERROR` / `UNKNOWN_CURRENCY` paths, structural
|
||||
payload validation) are listed in the design rationale; they have no
|
||||
upstream analogue because upstream raised plain `Error` and ignored
|
||||
payload shape.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| Class-based `BinanceProvider implements CurrencyProvider` | ✅ | ✅ | Identical constructor shape; takes `NoKeyConfig` now (super-set of upstream's optional `{ baseUrl }`). |
|
||||
| `name === "binance"`, `supportsCrypto === true`, `supportsFiat === false` | ✅ | ✅ | Verbatim. Exposed as `BINANCE_PROVIDER_NAME` for registry wiring. |
|
||||
| Default base URL `https://api.binance.com/api/v3` | ✅ | ✅ | Exported as `BINANCE_DEFAULT_BASE_URL` constant. |
|
||||
| Custom `baseUrl` override | ✅ | ✅ | Via `NoKeyConfig.baseUrl`. |
|
||||
| `GET /ticker/price` fetch | ✅ | ✅ | Now via slim-core `httpRequest`. |
|
||||
| Default base `USDT` | ✅ | ✅ | Verbatim. |
|
||||
| Suffix-match decoding (`BTCUSDT` → `BTC` when base is `USDT`) | ✅ | ✅ | Plus a defensive length check that rejects symbols equal to the suffix. |
|
||||
| Skip zero-price tickers | ✅ | ✅ | Verbatim. |
|
||||
| Skip negative-price / non-finite tickers | 🚫 | ⬆️ | Upstream only checked `price > 0` — fails open on `NaN`. Genesis form adds `Number.isFinite`. |
|
||||
| Optional `targets` filter | ✅ | ⬆️ | Upstream silently dropped missing targets; genesis raises `UNKNOWN_CURRENCY` so consumers learn about upstream listing changes immediately. |
|
||||
| Plain `Error` on non-2xx HTTP | ✅ | 🚫 | Replaced by typed `CurrencyProviderError(HTTP_ERROR)` raised by the slim core's `httpRequest`. Reason: the slim core's contract requires typed errors from every sibling. |
|
||||
| Structural payload validation | 🚫 | ⬆️ | Upstream cast the response shape without checking; genesis raises `UPSTREAM_ERROR` on malformed rows. |
|
||||
| `PARSE_ERROR` on non-JSON body | 🚫 | ⬆️ | Upstream would leak a runtime `SyntaxError`; genesis wraps `response.json()` in a typed catch. |
|
||||
| Injectable `FetchLike` | 🚫 | ⬆️ | Upstream used `globalThis.fetch` directly; genesis form takes a `NoKeyConfig.fetch` override for tests / proxies / edge runtimes. |
|
||||
| Optional per-request timeout | 🚫 | ⬆️ | New: `timeoutMs` flows through to `httpRequest` and composes with the caller's `signal`. |
|
||||
| `AbortSignal` propagation from `RateFetchOptions` | 🚫 | ⬆️ | Upstream did not honour `options.signal`; genesis forwards it through `httpRequest`. |
|
||||
| Upstream test suite (vi.spyOn-based fetch mock) | ✅ | ⬆️ | Rewritten to `@suaveplan/testing/runner` with a recording `FetchLike` closure — no `vi.mock`, no `vi.spyOn`. |
|
||||
| Sibling `.md` per source file + ≥200-word README | 🚫 | ⬆️ | Upstream `binance.md` was 46 lines; genesis form ships the §4 doc contract. |
|
||||
|
||||
No 🚫 row is left without justification.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-binance` to
|
||||
enumerate.
|
||||
|
||||
- No current dependents (verified by
|
||||
`bunx turbo ls --filter=...@suaveplan/currency-provider-binance` —
|
||||
this is a new package and downstream commerce stacks consume it
|
||||
through the `CurrencyProviderRegistry` lookup, not by direct import).
|
||||
- `@suaveplan/currency-providers` — [ ] tests run / [x] docs reviewed
|
||||
/ [x] no action needed (this package consumes the slim core; the
|
||||
slim core has no knowledge of any sibling and was not modified)
|
||||
- `@suaveplan/currency` — [ ] tests run / [x] docs reviewed / [x] no
|
||||
action needed (we depend on the `CurrencyProvider` contract — the
|
||||
contract is unchanged)
|
||||
|
||||
## Migration
|
||||
|
||||
There is no migration path because this is a new package. Consumers
|
||||
that previously used the upstream `BinanceProvider` from the
|
||||
pre-slim-refactor `@suaveplan/currency-providers` (which has never
|
||||
shipped on genesis) replace:
|
||||
|
||||
```ts
|
||||
// Before — upstream pre-slim-refactor form
|
||||
import { BinanceProvider } from "@suaveplan/currency-providers";
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// After — genesis sibling form
|
||||
import { BinanceProvider } from "@suaveplan/currency-provider-binance";
|
||||
```
|
||||
|
||||
Constructor signatures are compatible: `new BinanceProvider()` and
|
||||
`new BinanceProvider({ baseUrl: "…" })` both work unchanged. New
|
||||
capabilities (`fetch`, `timeoutMs`) are additive and optional.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The other eight sibling provider packages — each lands under its own
|
||||
OpenSpec change per AGENTS.md §0c.
|
||||
- Streaming / WebSocket / authenticated endpoints — REST-only for v1.
|
||||
- Retry orchestration, circuit breaker, rate-limit accounting — those
|
||||
ship as wrapper providers consuming the `CurrencyProvider` contract.
|
||||
@@ -0,0 +1,228 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: currency-provider-binance (add-currency-provider-binance)
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-binance`
|
||||
**Status**: ARCHIVED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring
|
||||
> time. No exceptions. This delta ADDS the canonical spec for the
|
||||
> Binance sibling of `@suaveplan/currency-providers`.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: BinanceProvider implements the CurrencyProvider contract on the Binance public spot ticker
|
||||
|
||||
The package SHALL export a `BinanceProvider` class implementing the
|
||||
`CurrencyProvider` contract from `@suaveplan/currency`. The class
|
||||
SHALL expose `name === "binance"`, `supportsCrypto === true`, and
|
||||
`supportsFiat === false`. The constructor SHALL accept an optional
|
||||
`NoKeyConfig & { timeoutMs?: number }` and SHALL default `baseUrl` to
|
||||
`"https://api.binance.com/api/v3"` and `fetch` to `globalThis.fetch`.
|
||||
`fetchRates(options?)` SHALL issue a single `GET /ticker/price` request
|
||||
through the shared `httpRequest` helper, default `options.base` to
|
||||
`"USDT"`, decode tickers whose symbol ends in the base suffix into a
|
||||
`Record<string, number>` of `1 / price`, optionally filter by
|
||||
`options.targets`, and return a `RateFetchResult` carrying `base`,
|
||||
`rates`, and a `Date.now()` `timestamp`. The package SHALL also export
|
||||
the stable constants `BINANCE_PROVIDER_NAME` (`"binance"`) and
|
||||
`BINANCE_DEFAULT_BASE_URL` (`"https://api.binance.com/api/v3"`).
|
||||
|
||||
#### Scenario: exposes the stable provider name and capability flags
|
||||
|
||||
- **WHEN** a new `BinanceProvider()` is constructed
|
||||
- **THEN** its `name === "binance"`, `supportsCrypto === true`, and
|
||||
`supportsFiat === false`
|
||||
|
||||
#### Scenario: defaults base to USDT and decodes USDT-quoted pairs to 1/price
|
||||
|
||||
- **WHEN** `fetchRates()` is called against a recording fetch that
|
||||
returns the sample ticker payload
|
||||
- **THEN** the result has `base === "USDT"` and `rates.BTC`, `rates.ETH`,
|
||||
`rates.SOL` each equal `1 / price` of the matching `*USDT` ticker
|
||||
|
||||
#### Scenario: filters by an explicit base suffix (EUR)
|
||||
|
||||
- **WHEN** `fetchRates({ base: "EUR" })` is called
|
||||
- **THEN** the result decodes only `*EUR` tickers — `rates.BTC` and
|
||||
`rates.ETH` are present (from `BTCEUR`, `ETHEUR`) and `rates.SOL` is
|
||||
absent (no `SOLEUR` ticker)
|
||||
|
||||
#### Scenario: filters to the requested targets when present
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USDT", targets: ["BTC", "ETH"] })` is
|
||||
called
|
||||
- **THEN** the result `rates` contains exactly `BTC` and `ETH` with
|
||||
their inverted prices and no other keys
|
||||
|
||||
#### Scenario: uses a custom baseUrl when provided
|
||||
|
||||
- **WHEN** the provider is constructed with
|
||||
`baseUrl: "https://custom.binance.example/api/v3"` and `fetchRates()`
|
||||
runs
|
||||
- **THEN** the recording fetch observes the URL
|
||||
`https://custom.binance.example/api/v3/ticker/price`
|
||||
|
||||
#### Scenario: exports the default REST base URL constant
|
||||
|
||||
- **WHEN** `BINANCE_DEFAULT_BASE_URL` is read
|
||||
- **THEN** the value is `"https://api.binance.com/api/v3"`
|
||||
|
||||
**Verified by:** `src/binance/binance.test.ts::"exposes the stable provider name and capability flags"` AND `src/binance/binance.test.ts::"defaults base to USDT and decodes USDT-quoted pairs to 1/price"` AND `src/binance/binance.test.ts::"filters by an explicit base suffix (EUR)"` AND `src/binance/binance.test.ts::"filters to the requested targets when present"` AND `src/binance/binance.test.ts::"uses a custom baseUrl when provided"` AND `src/binance/binance.test.ts::"exports the default REST base URL constant"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BinanceProvider sanitises non-usable ticker prices
|
||||
|
||||
The provider SHALL skip tickers whose decoded price is zero, negative,
|
||||
or non-finite (`NaN`), and SHALL skip tickers whose symbol exactly
|
||||
equals the requested base suffix (which would otherwise decode to an
|
||||
empty rate key). Skipped tickers SHALL NOT appear in the returned
|
||||
`rates` map and SHALL NOT raise an error — the upstream Binance
|
||||
endpoint occasionally publishes such rows for delisted symbols and they
|
||||
are not actionable signal.
|
||||
|
||||
#### Scenario: skips zero-price tickers
|
||||
|
||||
- **WHEN** the upstream returns `[{symbol:"BTCUSDT", price:"67000.00"},
|
||||
{symbol:"FAKUSDT", price:"0.00"}]`
|
||||
- **THEN** `rates.BTC` is defined and `rates.FAK` is absent
|
||||
|
||||
#### Scenario: skips negative-price tickers
|
||||
|
||||
- **WHEN** the upstream returns `[{symbol:"BADUSDT", price:"-1.00"}]`
|
||||
- **THEN** `rates.BAD` is absent
|
||||
|
||||
#### Scenario: skips tickers whose price is not finite
|
||||
|
||||
- **WHEN** the upstream returns
|
||||
`[{symbol:"NANUSDT", price:"not-a-number"}]`
|
||||
- **THEN** `rates.NAN` is absent
|
||||
|
||||
#### Scenario: skips a ticker whose symbol equals the suffix exactly
|
||||
|
||||
- **WHEN** the upstream returns
|
||||
`[{symbol:"USDT", price:"1.00"}, {symbol:"BTCUSDT", price:"67000.00"}]`
|
||||
- **THEN** `rates[""]` is absent and `rates.BTC` is defined
|
||||
|
||||
**Verified by:** `src/binance/binance.test.ts::"skips zero-price tickers"` AND `src/binance/binance.test.ts::"skips negative-price tickers"` AND `src/binance/binance.test.ts::"skips tickers whose price is not finite"` AND `src/binance/binance.test.ts::"skips a ticker whose symbol equals the suffix exactly"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BinanceProvider raises typed CurrencyProviderError for every failure path
|
||||
|
||||
The provider SHALL NOT throw raw `Error` instances. Every failure path
|
||||
SHALL raise a `CurrencyProviderError` carrying one of the slim core's
|
||||
codes: `HTTP_ERROR` (raised by `httpRequest` on a non-2xx response,
|
||||
with `status` and `providerName` populated), `PARSE_ERROR` (raised
|
||||
when `response.json()` rejects, with `cause` set when the rejection is
|
||||
an `Error`), `UPSTREAM_ERROR` (raised when the payload is not the
|
||||
expected `Array<{ symbol: string; price: string }>` shape — non-array
|
||||
root, null row, wrong-typed row, or missing field), and
|
||||
`UNKNOWN_CURRENCY` (raised when `options.targets` includes a target
|
||||
absent from the decoded rates map, with `upstreamMessage` set to the
|
||||
missing target name).
|
||||
|
||||
#### Scenario: propagates HTTP_ERROR raised by the shared httpRequest helper
|
||||
|
||||
- **WHEN** the injected fetch returns a `Response` with `status: 503`
|
||||
- **THEN** `fetchRates()` rejects with `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, and
|
||||
`providerName === "binance"`
|
||||
|
||||
#### Scenario: raises PARSE_ERROR when the response body is not JSON
|
||||
|
||||
- **WHEN** the response body is `"not json {{{"` with status 200
|
||||
- **THEN** `fetchRates()` rejects with `code === "CURRENCY_PROVIDER.PARSE_ERROR"`,
|
||||
`providerName === "binance"`, and `cause instanceof Error`
|
||||
|
||||
#### Scenario: raises PARSE_ERROR and omits cause when the rejection is non-Error
|
||||
|
||||
- **WHEN** `response.json()` rejects with a non-`Error` value (e.g. a
|
||||
string)
|
||||
- **THEN** `fetchRates()` rejects with `code === "CURRENCY_PROVIDER.PARSE_ERROR"`
|
||||
and `cause === undefined`
|
||||
|
||||
#### Scenario: raises UPSTREAM_ERROR when the payload is not an array
|
||||
|
||||
- **WHEN** the response body is `{"error":"rate limited"}` with status 200
|
||||
- **THEN** `fetchRates()` rejects with
|
||||
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"` and
|
||||
`providerName === "binance"`
|
||||
|
||||
#### Scenario: raises UPSTREAM_ERROR when a row is null
|
||||
|
||||
- **WHEN** the response body is `[null]`
|
||||
- **THEN** `fetchRates()` rejects with
|
||||
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`
|
||||
|
||||
#### Scenario: raises UPSTREAM_ERROR when a row has the wrong shape
|
||||
|
||||
- **WHEN** a row's `price` is a `number` instead of a `string`
|
||||
- **THEN** `fetchRates()` rejects with
|
||||
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`
|
||||
|
||||
#### Scenario: raises UPSTREAM_ERROR when a row is missing the symbol field
|
||||
|
||||
- **WHEN** a row is `{ price: "1.00" }` with no `symbol`
|
||||
- **THEN** `fetchRates()` rejects with
|
||||
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`
|
||||
|
||||
#### Scenario: raises UNKNOWN_CURRENCY when a requested target is not listed
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USDT", targets: ["BTC", "DOES_NOT_EXIST"] })`
|
||||
is called against the sample payload
|
||||
- **THEN** `fetchRates()` rejects with
|
||||
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`,
|
||||
`providerName === "binance"`, and `upstreamMessage === "DOES_NOT_EXIST"`
|
||||
|
||||
**Verified by:** `src/binance/binance.test.ts::"propagates HTTP_ERROR raised by the shared httpRequest helper"` AND `src/binance/binance.test.ts::"raises PARSE_ERROR when the response body is not JSON"` AND `src/binance/binance.test.ts::"raises PARSE_ERROR and omits cause when the rejection is non-Error"` AND `src/binance/binance.test.ts::"raises UPSTREAM_ERROR when the payload is not an array"` AND `src/binance/binance.test.ts::"raises UPSTREAM_ERROR when a row is null"` AND `src/binance/binance.test.ts::"raises UPSTREAM_ERROR when a row has the wrong shape"` AND `src/binance/binance.test.ts::"raises UPSTREAM_ERROR when a row is missing the symbol field"` AND `src/binance/binance.test.ts::"raises UNKNOWN_CURRENCY when a requested target is not listed"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BinanceProvider forwards caller AbortSignal and timeoutMs to httpRequest
|
||||
|
||||
The provider SHALL forward `options.signal` to the slim core's
|
||||
`httpRequest` helper unchanged. When the constructor was given
|
||||
`timeoutMs`, the provider SHALL forward that value to `httpRequest` so
|
||||
the helper derives an `AbortSignal.timeout(ms)` and composes it with
|
||||
the caller's signal. The provider SHALL NOT swallow abort or timeout
|
||||
rejections.
|
||||
|
||||
#### Scenario: forwards a caller AbortSignal through to httpRequest
|
||||
|
||||
- **WHEN** `fetchRates({ signal: controller.signal })` is called and
|
||||
`controller.abort(new Error("caller cancelled"))` fires before the
|
||||
fetch resolves
|
||||
- **THEN** the call rejects with an error whose message matches
|
||||
`/caller cancelled/`
|
||||
|
||||
#### Scenario: forwards timeoutMs through to httpRequest
|
||||
|
||||
- **WHEN** the provider is constructed with `timeoutMs: 5` against a
|
||||
fetch that never resolves
|
||||
- **THEN** the call rejects (with the timeout abort reason)
|
||||
|
||||
**Verified by:** `src/binance/binance.test.ts::"forwards a caller AbortSignal through to httpRequest"` AND `src/binance/binance.test.ts::"forwards timeoutMs through to httpRequest"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BinanceProvider falls back to globalThis.fetch when no FetchLike is injected
|
||||
|
||||
The provider SHALL default the `FetchLike` to a closure over
|
||||
`globalThis.fetch` when the constructor is invoked with no `fetch`
|
||||
override. The fallback SHALL preserve the exact URL and `init`
|
||||
forwarding contract of the injected variant — `httpRequest` SHALL see
|
||||
no behavioural difference between an injected and a default fetch.
|
||||
|
||||
#### Scenario: falls back to globalThis.fetch when no FetchLike is injected
|
||||
|
||||
- **WHEN** `new BinanceProvider()` is constructed (no `fetch` option),
|
||||
`globalThis.fetch` is temporarily replaced by a recording stub that
|
||||
returns the sample payload, and `fetchRates({ base: "USDT" })` runs
|
||||
- **THEN** the result `rates.BTC` is defined and the recorded URL
|
||||
equals `${BINANCE_DEFAULT_BASE_URL}/ticker/price`
|
||||
|
||||
**Verified by:** `src/binance/binance.test.ts::"falls back to globalThis.fetch when no FetchLike is injected"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,92 @@
|
||||
# Tasks — Add @suaveplan/currency-provider-binance
|
||||
|
||||
## Spec-completeness preflight (AGENTS.md §12a)
|
||||
|
||||
- [x] Numbered checklist of every `### Requirement` and `#### Scenario`
|
||||
drafted in `specs/currency-provider-binance/spec.md` (5
|
||||
requirements, 18 scenarios — see
|
||||
`./specs/currency-provider-binance/spec.md`).
|
||||
|
||||
## Scaffold the package
|
||||
|
||||
- [x] Create `packages/finance/currency-provider-binance/` directory
|
||||
- [x] Author `package.json` — name, exports map (root + single subpath
|
||||
`./binance/binance`), deps on `@suaveplan/currency`,
|
||||
`@suaveplan/currency-providers`, `@suaveplan/error`,
|
||||
`@suaveplan/types`, `tslib`; dev-deps on `@suaveplan/testing`,
|
||||
vitest, vite, vite-plugin-dts, `@vitest/coverage-istanbul`
|
||||
- [x] Author `tsconfig.json` + `tsconfig.lib.json` mirroring the slim
|
||||
core
|
||||
- [x] Author `vite.config.ts` with the same external list as the slim
|
||||
core plus `@suaveplan/currency-providers`
|
||||
- [x] Author `vitest.config.ts` with 100/100/100/100 istanbul thresholds
|
||||
- [x] Author `tests/setup/polyfills.ts` (no polyfills needed —
|
||||
universal-tier)
|
||||
|
||||
## Implement the provider
|
||||
|
||||
- [x] `src/binance/binance.ts` — `BinanceProvider` class,
|
||||
`BINANCE_PROVIDER_NAME`, `BINANCE_DEFAULT_BASE_URL`,
|
||||
`ensureTickerArray` shape guard, `decodeRates` suffix decoder,
|
||||
`filterTargets` raising `UNKNOWN_CURRENCY`, `httpRequest`
|
||||
delegation with caller signal + timeout forwarding, `PARSE_ERROR`
|
||||
wrapping of `response.json()`
|
||||
- [x] `src/binance/binance.md` — sibling doc, six required sections,
|
||||
≥200 words
|
||||
- [x] `src/binance/binance.test.ts` — happy path (default + custom
|
||||
base), targets filter, custom baseUrl, sanitisation (zero,
|
||||
negative, non-finite, suffix-equals-symbol), every error code
|
||||
reachable, signal forwarding, timeout forwarding, default-fetch
|
||||
fallback
|
||||
- [x] `src/binance/index.ts` — explicit named barrel
|
||||
- [x] `src/index.ts` — explicit named barrel re-exporting the three
|
||||
public symbols
|
||||
|
||||
## Documentation
|
||||
|
||||
- [x] `README.md` — H1, one-paragraph description, install snippet,
|
||||
quick start, features list, Modules list linking the sibling
|
||||
`.md`, Status note
|
||||
- [x] Confirm every `.ts` in `src/` has a sibling `.md` (the §13 step-5
|
||||
grep returns zero)
|
||||
|
||||
## Pre-archive gates (AGENTS.md §13)
|
||||
|
||||
- [x] Step 0 — spec reconciliation: every `### Requirement` and
|
||||
`**Verified by:**` resolves to a real file + verbatim test name
|
||||
- [x] Step 1 — `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-binance`
|
||||
green and `bun run check` green
|
||||
- [x] Step 2 — `bunx turbo test:coverage --filter=@suaveplan/currency-provider-binance`
|
||||
reports 100/100/100/100 via istanbul
|
||||
- [x] Step 3 — `bunx turbo build --filter=@suaveplan/currency-provider-binance` green
|
||||
- [x] Step 4 — banned-pattern grep returns zero
|
||||
- [x] Step 4a — barrel-shape grep returns zero
|
||||
- [x] Step 4b — test-runner-import grep returns zero
|
||||
- [x] Step 5 — missing-`.md` grep returns zero
|
||||
- [x] Step 6 — README present
|
||||
- [x] Step 7 — bare-version grep returns zero
|
||||
- [x] Step 8 — subpath-export count = 1 (matches feature module count)
|
||||
- [x] Step 9 — security-scan grep returns zero
|
||||
- [x] Step 10 — dependent sweep
|
||||
(`bunx turbo test --filter=...@suaveplan/currency-provider-binance`)
|
||||
|
||||
## Root-doc refresh (AGENTS.md §13b)
|
||||
|
||||
- [x] Update `scripts/folder-reorg/assignment.yml` — add
|
||||
`currency-provider-binance` under the `commerce:` block
|
||||
- [x] Re-run `bun scripts/folder-reorg/generate-manifest.ts` to refresh
|
||||
`MANIFEST.md`
|
||||
- [x] Re-run `bun scripts/generate-missing.ts` to refresh `MISSING.yml`
|
||||
- [x] Update `STATUS.md` `commerce` table to add the
|
||||
`@suaveplan/currency-provider-binance` row with the shipped
|
||||
surface summary
|
||||
- [x] Update `SUMMARY.md` with a per-package paragraph
|
||||
- [x] `bunx markdownlint-cli2 --fix` across touched root + change files
|
||||
|
||||
## Archive (AGENTS.md §14)
|
||||
|
||||
- [x] Fold `specs/currency-provider-binance/spec.md` into
|
||||
`openspec/specs/currency-provider-binance/spec.md`
|
||||
- [x] Move `openspec/changes/add-currency-provider-binance/` →
|
||||
`openspec/archive/2026-05-17-add-currency-provider-binance/`
|
||||
(authored directly in archive per the task instructions)
|
||||
Reference in New Issue
Block a user