Initial Commit
This commit is contained in:
@@ -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 -->
|
||||
Reference in New Issue
Block a user