Files
2026-05-27 23:29:53 +01:00

227 lines
11 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: currency-provider-binance
**Package**: `@suaveplan/currency-provider-binance`
> Canonical spec for the Binance sibling of `@suaveplan/currency-providers`.
> Folded from `openspec/archive/2026-05-17-add-currency-provider-binance/`.
## 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 -->