Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
# @suaveplan/currency-provider-binance - Universal Binance public-ticker crypto-rates provider for @suaveplan/currency. Sibling provider package: implements the CurrencyProvider contract from @suaveplan/currency on top of the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). No API key required.
Binance public-ticker crypto-rates provider for `@suaveplan/currency`.
Sibling package in the foundation-with-sibling-plugins shape: implements
the `CurrencyProvider` contract on top of the slim
`@suaveplan/currency-providers` core (`httpRequest`,
`CurrencyProviderError`, `NoKeyConfig`). Universal — runs unchanged
under Node 18+, Bun, modern browsers, and Web Workers. No API key
required.
<!-- begin:auto-docs -->
## Documentation tree
- [binance](./src/binance/binance.md) — `BinanceProvider` is the Binance sibling implementation of the `CurrencyProvider` contract from `@suaveplan/currency`.…
- [binance](./src/binance/binance.md) — `BinanceProvider` is the Binance sibling implementation of the `CurrencyProvider` contract from `@suaveplan/currency`.…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-binance @suaveplan/currency @suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { BinanceProvider } from "@suaveplan/currency-provider-binance";
const registry = new CurrencyProviderRegistry();
registry.register(new BinanceProvider());
const converter = new CurrencyConverter({
provider: registry.get("binance"),
staleDurationMs: 30_000,
});
const rate = await converter.getRate("USDT", "BTC");
```
Direct instantiation without the registry works exactly the same:
```ts
const converter = new CurrencyConverter({
provider: new BinanceProvider(),
});
```
## Features
- Public Binance spot ticker endpoint (`/api/v3/ticker/price`) — free,
no API key, no signature, no OAuth
- Default base `USDT`; any suffix-style base supported by Binance works
(`EUR`, `BTC`, `ETH`, etc.) by passing `base` to `fetchRates`
- Optional `targets` filter raises typed `UNKNOWN_CURRENCY` for any
target absent from the upstream listing — surfaces missing pairs
eagerly rather than silently dropping them
- Sanitised pricing: zero, negative, and non-finite ticker prices are
skipped (delisted-but-published symbols stay out of the cache)
- Rate inversion: every rate stored as `1 / price` so the cache answers
"1 unit of base = N units of crypto"
- Injectable `FetchLike` for tests, proxies, and edge-runtime adapters;
optional `timeoutMs` flows through to the shared `httpRequest` helper
- Typed errors via `CurrencyProviderError` carrying one of `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY` — no plain
`Error` ever escapes
- Universal; explicit named barrel; one subpath export beyond the
root; 100/100/100/100 istanbul coverage
## Modules
- [`src/binance/binance.md`](./src/binance/binance.md) — `BinanceProvider`
class, `BINANCE_PROVIDER_NAME`, `BINANCE_DEFAULT_BASE_URL`
## Status
`0.1.0` (pre-graduation). Follow-up to the 2026-05-17 slim refactor of
`@suaveplan/currency-providers` — see
the design proposal
for the per-package change record and
the design document for the
cross-cutting sibling-shape architecture.

View File

@@ -0,0 +1,68 @@
{
"name": "@suaveplan/currency-provider-binance",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal Binance public-ticker crypto-rates provider for @suaveplan/currency. Sibling provider package: implements the CurrencyProvider contract from @suaveplan/currency on top of the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). No API key required.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./binance/binance": {
"import": {
"types": "./dist/binance/binance.d.ts",
"default": "./dist/binance/binance.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/currency": "workspace:^",
"@suaveplan/currency-providers": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,159 @@
# BinanceProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BinanceProvider` is the Binance sibling implementation of the
`CurrencyProvider` contract from `@suaveplan/currency`. It fetches the
public Binance spot ticker (`GET /api/v3/ticker/price`) — a free,
key-less endpoint that returns the latest mid-price for every listed
trading pair — and decodes it into a `RateFetchResult` ready for the
`CurrencyConverter`'s cache. The provider is universal with a
shape that mirrors every other `@suaveplan/currency-provider-<name>`
sibling: a `NoKeyConfig`-shaped constructor with optional `baseUrl`
override and optional injectable `FetchLike`, a `fetchRates` method that
delegates HTTP transport to the slim core's `httpRequest` helper, and
typed `CurrencyProviderError`s for every failure mode (no plain
`Error`).
## Features
- Public Binance ticker endpoint — no API key, no OAuth, no signature
- Default base `USDT`; any other suffix-style base supported by Binance
(e.g. `EUR`, `BTC`, `ETH`) works by passing `base` to `fetchRates`
- Optional `targets` filter raises `UNKNOWN_CURRENCY` for any target the
upstream does not list against the requested base — surfaces missing
pairs eagerly rather than silently dropping them
- Suffix matching: a ticker `BTCUSDT` becomes `BTC` when base is `USDT`;
symbols equal to the suffix (`USDTUSDT` quirks) are defensively skipped
- Sanitised pricing: zero, negative, and non-finite prices are skipped
- Rate inversion: every rate is stored as `1 / price` so the cache
answers "1 unit of base = N units of crypto"
- Injectable `FetchLike` for tests, proxies, and edge-runtime adapters
- Optional `timeoutMs` flows through to `httpRequest` and derives an
`AbortSignal.timeout(ms)` composed with the caller's `signal`
- Typed errors via `CurrencyProviderError` + the 8-code
`CURRENCY_PROVIDER_*` registry — `HTTP_ERROR` (non-2xx), `PARSE_ERROR`
(non-JSON body), `UPSTREAM_ERROR` (unexpected shape),
`UNKNOWN_CURRENCY` (target absent from upstream listing)
## Usage: Basic
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { BinanceProvider } from "@suaveplan/currency-provider-binance";
const provider = new BinanceProvider();
const converter = new CurrencyConverter({
provider,
staleDurationMs: 30_000,
});
const rate = await converter.getRate("USDT", "BTC");
console.log(rate);
```
## Usage: Advanced
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import {
BinanceProvider,
BINANCE_PROVIDER_NAME,
} from "@suaveplan/currency-provider-binance";
import type { FetchLike } from "@suaveplan/types";
// Wire a constrained FetchLike (corporate proxy, retry harness, etc.).
const proxiedFetch: FetchLike = async (url, init) => {
return await fetch(`https://proxy.example.com/${String(url)}`, init);
};
const binance = new BinanceProvider({
fetch: proxiedFetch,
baseUrl: "https://api.binance.com/api/v3",
timeoutMs: 8_000,
});
const registry = new CurrencyProviderRegistry();
registry.register(binance);
// Fetch only the pairs we care about — UNKNOWN_CURRENCY surfaces any
// upstream listing changes immediately.
const result = await registry
.get(BINANCE_PROVIDER_NAME)
.fetchRates({ base: "USDT", targets: ["BTC", "ETH", "SOL"] });
console.log(result.rates);
```
## API Reference
### `BINANCE_PROVIDER_NAME: "binance"`
Stable provider identifier — surfaced through `provider.name` and used
as the registry key.
### `BINANCE_DEFAULT_BASE_URL: "https://api.binance.com/api/v3"`
Default REST base URL. Overridable via the constructor `baseUrl` for
staging or proxy deployments.
### `class BinanceProvider implements CurrencyProvider`
Constructor: `new BinanceProvider(config?: NoKeyConfig & { timeoutMs?: number })`.
- `config.baseUrl` — overrides `BINANCE_DEFAULT_BASE_URL`
- `config.fetch` — optional injectable `FetchLike`; defaults to
`globalThis.fetch`
- `config.timeoutMs` — optional per-request timeout in milliseconds; when
set, derives an `AbortSignal.timeout` composed with the caller's
`signal`
Properties:
- `name === "binance"` (literal)
- `supportsCrypto === true`
- `supportsFiat === false`
Method: `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`.
- `options.base` — base currency suffix (default `"USDT"`)
- `options.targets` — optional list of target currencies; raises
`UNKNOWN_CURRENCY` for any target absent from the upstream listing
- `options.signal` — optional `AbortSignal` forwarded to `httpRequest`
Throws `CurrencyProviderError` with one of:
- `CURRENCY_PROVIDER.HTTP_ERROR` — non-2xx response from Binance
- `CURRENCY_PROVIDER.PARSE_ERROR` — body is not valid JSON
- `CURRENCY_PROVIDER.UPSTREAM_ERROR` — payload is not the expected
`Array<{ symbol: string; price: string }>` shape
- `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` — a requested target is not
listed against the requested base
## Implementation Notes
- Suffix matching: `endsWith(base)` plus a length check rejects symbols
whose entire name equals the suffix (`USDTUSDT`-style edge cases),
which would otherwise decode to an empty `""` rate key
- Pricing: `Number.parseFloat` plus `Number.isFinite && price > 0` is
the canonical "is this a usable mid-price" filter — zero, negative,
and `NaN` values are silently skipped (not surfaced as errors) because
Binance occasionally returns those for delisted-but-still-published
symbols
- Rate inversion: storing `1 / price` keeps the `RateFetchResult.rates`
map answering "1 unit of base = N units of asset", matching the
convention used by the rest of `@suaveplan/currency`
- Transport: HTTP is delegated entirely to the slim core's
`httpRequest` so every sibling provider raises identical `HTTP_ERROR`
payloads, supports the same abort/timeout composition, and benefits
from any future improvements (retry, circuit breaker) made there
- JSON parsing: `response.json()` is wrapped so non-JSON bodies become
`PARSE_ERROR` rather than leaking a runtime `SyntaxError`; payload
shape validation is a typed structural check (no runtime Zod cost in
the universal core)
- IP-based rate limiting still applies upstream; the provider is
intentionally rate-limit-agnostic — wrap it in the future
`RateLimitedProvider` when that ships

View File

@@ -0,0 +1,412 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import {
BINANCE_DEFAULT_BASE_URL,
BINANCE_PROVIDER_NAME,
BinanceProvider,
} from "./binance.js";
interface FetchCall {
readonly url: string;
readonly init: RequestInit | undefined;
}
function recordingFetch(
handler: (call: FetchCall) => Promise<Response> | Response
): { fetch: FetchLike; calls: FetchCall[] } {
const calls: FetchCall[] = [];
const fetchImpl: FetchLike = async (
url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
const call: FetchCall = { url: String(url), init };
calls.push(call);
return await handler(call);
};
return { fetch: fetchImpl, calls };
}
const SAMPLE_TICKERS = [
{ symbol: "BTCUSDT", price: "67000.00" },
{ symbol: "ETHUSDT", price: "3500.00" },
{ symbol: "SOLUSDT", price: "150.00" },
{ symbol: "BTCEUR", price: "62000.00" },
{ symbol: "ETHEUR", price: "3200.00" },
];
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("BinanceProvider — identity", () => {
it("exposes the stable provider name and capability flags", {
timeout: 5000,
}, () => {
const provider = new BinanceProvider();
expect(provider.name).toBe(BINANCE_PROVIDER_NAME);
expect(BINANCE_PROVIDER_NAME).toBe("binance");
expect(provider.supportsCrypto).toBe(true);
expect(provider.supportsFiat).toBe(false);
});
it("exports the default REST base URL constant", { timeout: 5000 }, () => {
expect(BINANCE_DEFAULT_BASE_URL).toBe("https://api.binance.com/api/v3");
});
});
describe("BinanceProvider — fetchRates happy path", () => {
it("defaults base to USDT and decodes USDT-quoted pairs to 1/price", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(SAMPLE_TICKERS)
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates();
expect(result.base).toBe("USDT");
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
expect(result.rates.SOL).toBeCloseTo(1 / 150, 12);
expect(typeof result.timestamp).toBe("number");
expect(calls[0]?.url).toBe(`${BINANCE_DEFAULT_BASE_URL}/ticker/price`);
});
it("filters by an explicit base suffix (EUR)", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse(SAMPLE_TICKERS));
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "EUR" });
expect(result.base).toBe("EUR");
expect(result.rates.BTC).toBeCloseTo(1 / 62000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3200, 12);
expect(result.rates.SOL).toBeUndefined();
});
it("filters to the requested targets when present", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse(SAMPLE_TICKERS));
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({
base: "USDT",
targets: ["BTC", "ETH"],
});
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "ETH"]);
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
});
it("uses a custom baseUrl when provided", { timeout: 5000 }, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(SAMPLE_TICKERS)
);
const provider = new BinanceProvider({
fetch,
baseUrl: "https://custom.binance.example/api/v3",
});
await provider.fetchRates();
expect(calls[0]?.url).toBe(
"https://custom.binance.example/api/v3/ticker/price"
);
});
});
describe("BinanceProvider — sanitisation", () => {
it("skips zero-price tickers", { timeout: 5000 }, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([
{ symbol: "BTCUSDT", price: "67000.00" },
{ symbol: "FAKUSDT", price: "0.00" },
])
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "USDT" });
expect(result.rates.BTC).toBeDefined();
expect(result.rates.FAK).toBeUndefined();
});
it("skips negative-price tickers", { timeout: 5000 }, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([{ symbol: "BADUSDT", price: "-1.00" }])
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "USDT" });
expect(result.rates.BAD).toBeUndefined();
});
it("skips tickers whose price is not finite", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([{ symbol: "NANUSDT", price: "not-a-number" }])
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "USDT" });
expect(result.rates.NAN).toBeUndefined();
});
it("skips a ticker whose symbol equals the suffix exactly", {
timeout: 5000,
}, async () => {
// `USDTUSDT` would decode to an empty key — defensively skipped.
const { fetch } = recordingFetch(() =>
jsonResponse([
{ symbol: "USDT", price: "1.00" },
{ symbol: "BTCUSDT", price: "67000.00" },
])
);
const provider = new BinanceProvider({ fetch });
const result = await provider.fetchRates({ base: "USDT" });
expect(Object.hasOwn(result.rates, "")).toBe(false);
expect(result.rates.BTC).toBeDefined();
});
});
describe("BinanceProvider — error paths", () => {
it("propagates HTTP_ERROR raised by the shared httpRequest helper", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("upstream down", {
status: 503,
statusText: "Service Unavailable",
})
);
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
expect(err.status).toBe(503);
expect(err.providerName).toBe(BINANCE_PROVIDER_NAME);
}
});
it("raises PARSE_ERROR when the response body is not JSON", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response("not json {{{", { status: 200 })
);
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
expect(err.providerName).toBe(BINANCE_PROVIDER_NAME);
expect(err.cause).toBeInstanceOf(Error);
}
});
it("raises PARSE_ERROR and omits cause when the rejection is non-Error", {
timeout: 5000,
}, async () => {
// Body whose .json() rejects with a non-Error throwable — exercises
// the `cause instanceof Error ? cause : undefined` branch.
const badResponse: Response = new Response("ignored", { status: 200 });
Object.defineProperty(badResponse, "json", {
value: async () => {
const failure: unknown = "string-rejection";
throw failure;
},
});
const { fetch } = recordingFetch(() => badResponse);
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
expect(err.cause).toBeUndefined();
}
});
it("raises UPSTREAM_ERROR when the payload is not an array", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({ error: "rate limited" })
);
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
expect(err.providerName).toBe(BINANCE_PROVIDER_NAME);
}
});
it("raises UPSTREAM_ERROR when a row is null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse([null]));
const provider = new BinanceProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR when a row has the wrong shape", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([{ symbol: "BTCUSDT", price: 67000 }])
);
const provider = new BinanceProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR when a row is missing the symbol field", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse([{ price: "1.00" }])
);
const provider = new BinanceProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UNKNOWN_CURRENCY when a requested target is not listed", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse(SAMPLE_TICKERS));
const provider = new BinanceProvider({ fetch });
try {
await provider.fetchRates({
base: "USDT",
targets: ["BTC", "DOES_NOT_EXIST"],
});
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
expect(err.providerName).toBe(BINANCE_PROVIDER_NAME);
expect(err.upstreamMessage).toBe("DOES_NOT_EXIST");
}
});
});
describe("BinanceProvider — abort + timeout", () => {
it("forwards a caller AbortSignal through to httpRequest", {
timeout: 5000,
}, async () => {
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
const controller = new AbortController();
const provider = new BinanceProvider({ fetch: fetchImpl });
const promise = provider.fetchRates({ signal: controller.signal });
controller.abort(new Error("caller cancelled"));
await expect(promise).rejects.toThrow(/caller cancelled/);
});
it("forwards timeoutMs through to httpRequest", {
timeout: 5000,
}, async () => {
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
const provider = new BinanceProvider({
fetch: fetchImpl,
timeoutMs: 5,
});
await expect(provider.fetchRates()).rejects.toThrow();
});
});
describe("BinanceProvider — default fetch wiring", () => {
it("falls back to globalThis.fetch when no FetchLike is injected", {
timeout: 5000,
}, async () => {
const originalFetch = globalThis.fetch;
let observedUrl = "";
const stubFetch: typeof globalThis.fetch = async (input, init) => {
observedUrl = String(input);
void init;
return jsonResponse(SAMPLE_TICKERS);
};
globalThis.fetch = stubFetch;
try {
const provider = new BinanceProvider();
const result = await provider.fetchRates({ base: "USDT" });
expect(result.rates.BTC).toBeDefined();
expect(observedUrl).toBe(
`${BINANCE_DEFAULT_BASE_URL}/ticker/price`
);
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -0,0 +1,156 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
/** Default Binance spot REST base URL — public endpoint, no API key. */
export const BINANCE_DEFAULT_BASE_URL = "https://api.binance.com/api/v3";
/** Stable provider identifier surfaced through {@link CurrencyProvider.name}. */
export const BINANCE_PROVIDER_NAME = "binance";
/** Wire-shape of a single ticker row in the `/ticker/price` response. */
interface BinanceTickerRow {
readonly symbol: string;
readonly price: string;
}
function ensureTickerArray(payload: unknown): readonly BinanceTickerRow[] {
if (!Array.isArray(payload)) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
"Binance /ticker/price returned a non-array payload",
{ providerName: BINANCE_PROVIDER_NAME }
);
}
for (const row of payload) {
if (
row === null ||
typeof row !== "object" ||
typeof (row as { symbol?: unknown }).symbol !== "string" ||
typeof (row as { price?: unknown }).price !== "string"
) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
"Binance /ticker/price returned a malformed ticker row",
{ providerName: BINANCE_PROVIDER_NAME }
);
}
}
return payload as readonly BinanceTickerRow[];
}
function decodeRates(
rows: readonly BinanceTickerRow[],
suffix: string
): Record<string, number> {
const rates: Record<string, number> = {};
for (const ticker of rows) {
if (!ticker.symbol.endsWith(suffix)) continue;
if (ticker.symbol.length === suffix.length) continue;
const symbol = ticker.symbol.slice(0, -suffix.length);
const price = Number.parseFloat(ticker.price);
if (Number.isFinite(price) && price > 0) {
rates[symbol] = 1 / price;
}
}
return rates;
}
function filterTargets(
rates: Readonly<Record<string, number>>,
targets: readonly string[]
): Record<string, number> {
const out: Record<string, number> = {};
for (const target of targets) {
const value = rates[target];
if (value === undefined) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`Binance does not list pair ${target} against requested base`,
{
providerName: BINANCE_PROVIDER_NAME,
upstreamMessage: target,
}
);
}
out[target] = value;
}
return out;
}
/**
* Binance public-ticker crypto-rates provider. Implements the
* `CurrencyProvider` contract on top of Binance's free, key-less
* `/api/v3/ticker/price` endpoint. Returns the spot mid-price of every
* listed trading pair whose symbol ends in the requested base suffix
* (e.g. `BTCUSDT` → `BTC` when base is `USDT`), inverted to
* `1 / price` so the rate map answers "1 base = N units of asset".
*/
export class BinanceProvider implements CurrencyProvider {
public readonly name = BINANCE_PROVIDER_NAME;
public readonly supportsCrypto = true;
public readonly supportsFiat = false;
private readonly baseUrl: string;
private readonly fetchImpl: NonNullable<NoKeyConfig["fetch"]>;
private readonly timeoutMs: number | undefined;
public constructor(config?: NoKeyConfig & { readonly timeoutMs?: number }) {
this.baseUrl = config?.baseUrl ?? BINANCE_DEFAULT_BASE_URL;
const injected = config?.fetch;
this.fetchImpl =
injected ?? ((url, init) => globalThis.fetch(url, init));
this.timeoutMs = config?.timeoutMs;
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const base = options?.base ?? "USDT";
const url = `${this.baseUrl}/ticker/price`;
const response = await httpRequest({
url,
fetch: this.fetchImpl,
providerName: BINANCE_PROVIDER_NAME,
...(options?.signal !== undefined
? { signal: options.signal }
: {}),
...(this.timeoutMs !== undefined
? { timeoutMs: this.timeoutMs }
: {}),
});
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"Binance /ticker/price returned a non-JSON body",
{
providerName: BINANCE_PROVIDER_NAME,
cause: cause instanceof Error ? cause : undefined,
}
);
}
const rows = ensureTickerArray(payload);
const rates = decodeRates(rows, base);
const finalRates =
options?.targets !== undefined
? filterTargets(rates, options.targets)
: rates;
return { base, rates: finalRates, timestamp: Date.now() };
}
}

View File

@@ -0,0 +1,5 @@
export {
BINANCE_DEFAULT_BASE_URL,
BINANCE_PROVIDER_NAME,
BinanceProvider,
} from "./binance.js";

View File

@@ -0,0 +1,5 @@
export {
BINANCE_DEFAULT_BASE_URL,
BINANCE_PROVIDER_NAME,
BinanceProvider,
} from "./binance/binance.js";

View File

@@ -0,0 +1,6 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `Response`, `Number.parseFloat`, `Date.now`, the WHATWG `fetch`
// surface, and an injected `FetchLike` closure — all available in every
// supported runtime (Node 18+, Bun, browsers, Web Workers). This file exists
// so vitest has a stable setup file path mirroring the sibling-package layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/currency-provider-binance",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/currency",
"@suaveplan/currency-providers",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});