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

11 KiB

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"