229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
<!-- 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 -->
|