Initial Commit
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
BINANCE_DEFAULT_BASE_URL,
|
||||
BINANCE_PROVIDER_NAME,
|
||||
BinanceProvider,
|
||||
} from "./binance.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
BINANCE_DEFAULT_BASE_URL,
|
||||
BINANCE_PROVIDER_NAME,
|
||||
BinanceProvider,
|
||||
} from "./binance/binance.js";
|
||||
Reference in New Issue
Block a user