# 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` 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"`