11 KiB
Capability Spec: currency-provider-binance
Package: @suaveplan/currency-provider-binance
Canonical spec for the Binance sibling of
@suaveplan/currency-providers. Folded fromopenspec/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, andsupportsFiat === 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"andrates.BTC,rates.ETH,rates.SOLeach equal1 / priceof the matching*USDTticker
Scenario: filters by an explicit base suffix (EUR)
- WHEN
fetchRates({ base: "EUR" })is called - THEN the result decodes only
*EURtickers —rates.BTCandrates.ETHare present (fromBTCEUR,ETHEUR) andrates.SOLis absent (noSOLEURticker)
Scenario: filters to the requested targets when present
- WHEN
fetchRates({ base: "USDT", targets: ["BTC", "ETH"] })is called - THEN the result
ratescontains exactlyBTCandETHwith 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"andfetchRates()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_URLis 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.BTCis defined andrates.FAKis absent
Scenario: skips negative-price tickers
- WHEN the upstream returns
[{symbol:"BADUSDT", price:"-1.00"}] - THEN
rates.BADis absent
Scenario: skips tickers whose price is not finite
- WHEN the upstream returns
[{symbol:"NANUSDT", price:"not-a-number"}] - THEN
rates.NANis 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 andrates.BTCis 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
Responsewithstatus: 503 - THEN
fetchRates()rejects withCurrencyProviderErrorwhosecode === "CURRENCY_PROVIDER.HTTP_ERROR",status === 503, andproviderName === "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 withcode === "CURRENCY_PROVIDER.PARSE_ERROR",providerName === "binance", andcause instanceof Error
Scenario: raises PARSE_ERROR and omits cause when the rejection is non-Error
- WHEN
response.json()rejects with a non-Errorvalue (e.g. a string) - THEN
fetchRates()rejects withcode === "CURRENCY_PROVIDER.PARSE_ERROR"andcause === 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 withcode === "CURRENCY_PROVIDER.UPSTREAM_ERROR"andproviderName === "binance"
Scenario: raises UPSTREAM_ERROR when a row is null
- WHEN the response body is
[null] - THEN
fetchRates()rejects withcode === "CURRENCY_PROVIDER.UPSTREAM_ERROR"
Scenario: raises UPSTREAM_ERROR when a row has the wrong shape
- WHEN a row's
priceis anumberinstead of astring - THEN
fetchRates()rejects withcode === "CURRENCY_PROVIDER.UPSTREAM_ERROR"
Scenario: raises UPSTREAM_ERROR when a row is missing the symbol field
- WHEN a row is
{ price: "1.00" }with nosymbol - THEN
fetchRates()rejects withcode === "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 withcode === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY",providerName === "binance", andupstreamMessage === "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 andcontroller.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: 5against 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 (nofetchoption),globalThis.fetchis temporarily replaced by a recording stub that returns the sample payload, andfetchRates({ base: "USDT" })runs - THEN the result
rates.BTCis 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"