@forgezero/providers 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ForgeZero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @forgezero/providers
2
+
3
+ **One service, several vendors, automatic failover — and a registry that knows
4
+ which failures are worth retrying.**
5
+
6
+ You declare a service (`email`, `chain`, `storage`) and the providers that can
7
+ serve it, in priority order. Calls try them in turn. Where the credentials come
8
+ from is yours: environment variables in development, a vault in production,
9
+ nothing changes in your code.
10
+
11
+ Zero runtime dependencies. Bun, Node 18+, Deno, Cloudflare Workers — anywhere
12
+ `fetch` and Web Crypto exist.
13
+
14
+ ```bash
15
+ bun add @forgezero/providers
16
+ ```
17
+
18
+ ```ts
19
+ import { createRegistry, envCredentials, staticConfig } from '@forgezero/providers';
20
+ import { jetemail, smtp } from '@forgezero/providers/email';
21
+
22
+ const services = createRegistry({
23
+ credentials: envCredentials(process.env), // or vaultCredentials(fz)
24
+ config: staticConfig({
25
+ email: [
26
+ { providerId: 'jetemail', priority: 1, enabled: true, secretRef: 'jetemail', config: {} },
27
+ { providerId: 'smtp', priority: 2, enabled: true, secretRef: 'smtp', config: {} }
28
+ ]
29
+ }),
30
+ providers: [jetemail, smtp]
31
+ });
32
+
33
+ await services.invoke('email', { to, subject, html }); // JetEmail, then SMTP
34
+ ```
35
+
36
+ ## Why a 400 stops the loop and a 401 does not
37
+
38
+ This is the whole reason the package exists. Every failure is classified before
39
+ the loop decides what to do:
40
+
41
+ | verdict | meaning | what happens |
42
+ |---|---|---|
43
+ | `terminal` | the request itself is wrong — a 400, a malformed address | **stop.** Every other provider will reject it too |
44
+ | `retryable` | this vendor failed — a 401, a 500, a dropped socket | try the next one. Their key being bad says nothing about the next key |
45
+ | `backoff` | rate limited — a 429 with `Retry-After` | wait, then continue |
46
+
47
+ A registry that retries a 400 across five vendors sends five identical rejections
48
+ and reports "all providers down". A registry that gives up on a 401 takes your
49
+ whole service offline because one API key expired.
50
+
51
+ ## Health that reflects reality
52
+
53
+ Missing is healthy. One or two failures is **degraded** — still attempted, amber
54
+ in a dashboard. Three is **offline** — skipped entirely until a success clears
55
+ it. A provider disabled by configuration never counts against health, because a
56
+ vendor you turned off is not a vendor that is failing.
57
+
58
+ ## Subpaths
59
+
60
+ | import | what it is |
61
+ |---|---|
62
+ | `@forgezero/providers` | the registry, `defineProvider`, `classify`, health |
63
+ | `/email` | JetEmail and SMTP behind one `email` service |
64
+ | `/chain` | an EVM JSON-RPC node behind the same failover — logs, balance, broadcast |
65
+ | `/database` | ArangoDB, for credential rotation and health |
66
+ | `/http` | outbound HTTP with a per-host weight budget reserved before the call |
67
+ | `/pool` | which outbound address a request leaves by, sticky per key |
68
+ | `/storage` | S3-compatible object storage, SigV4 signed with Web Crypto, no vendor SDK |
69
+
70
+ Full documentation: **https://forgezero.net/docs/providers**
71
+
72
+ ## Licence
73
+
74
+ MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
75
+ deploys — and usable entirely on its own, with no ForgeZero account.
@@ -0,0 +1,27 @@
1
+ import { VenueError, type VenueAdapter, type MarketType, type OrderStatus } from '@forgezero/runtime/finance/venues';
2
+ export interface BinanceCredentials {
3
+ apiKey: string;
4
+ apiSecret: string;
5
+ }
6
+ export interface BinanceOptions {
7
+ credentials: BinanceCredentials;
8
+ /** Override for testnet, or for a test. */
9
+ hosts?: Partial<Record<MarketType, string>>;
10
+ fetch?: typeof globalThis.fetch;
11
+ /**
12
+ * How far a request may be delayed before Binance refuses it.
13
+ *
14
+ * 5s rather than the 60s maximum. A signed order that arrives a minute late
15
+ * is an order placed into a market that has moved, and accepting it is worse
16
+ * than being told to retry.
17
+ */
18
+ recvWindowMs?: number;
19
+ now?: () => number;
20
+ }
21
+ /** Binance spells `BTC/USDT` as `BTCUSDT`. Denormalised here and nowhere else. */
22
+ export declare const binanceSymbol: (symbol: string) => string;
23
+ /** Binance statuses → ours. An unknown one is `rejected`, never silently `accepted`. */
24
+ export declare function toOrderStatus(status: string): OrderStatus;
25
+ export declare function createBinanceAdapter(options: BinanceOptions): VenueAdapter;
26
+ /** Turn a Binance error into something that names the actual cause. */
27
+ export declare function readBinanceError(error: unknown): VenueError | undefined;
@@ -0,0 +1,493 @@
1
+ // src/index.ts
2
+ class ProviderError extends Error {
3
+ code;
4
+ details;
5
+ constructor(code, message, details) {
6
+ super(message);
7
+ this.code = code;
8
+ this.details = details;
9
+ this.name = "ProviderError";
10
+ }
11
+ }
12
+ function envCredentials(env) {
13
+ return {
14
+ name: "env",
15
+ async get(reference, field) {
16
+ const key = `${reference}_${field}`.replace(/[.-]/g, "_").toUpperCase();
17
+ const value = env[key];
18
+ if (value === undefined) {
19
+ throw new ProviderError("CREDENTIAL_MISSING", `Set ${key} in the environment.`);
20
+ }
21
+ return value;
22
+ }
23
+ };
24
+ }
25
+ function chainCredentials(...sources) {
26
+ return {
27
+ name: sources.map((source) => source.name).join("+"),
28
+ async get(reference, field) {
29
+ let last;
30
+ for (const source of sources) {
31
+ try {
32
+ return await source.get(reference, field);
33
+ } catch (error) {
34
+ last = error;
35
+ }
36
+ }
37
+ throw last instanceof Error ? last : new ProviderError("CREDENTIAL_MISSING", `No source held ${reference}.${field}.`);
38
+ }
39
+ };
40
+ }
41
+ function staticConfig(services) {
42
+ const health = new Map;
43
+ return {
44
+ name: "static",
45
+ async list(serviceKey) {
46
+ return (services[serviceKey] ?? []).map((provider) => ({
47
+ ...provider,
48
+ health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
49
+ }));
50
+ },
51
+ async recordHealth(serviceKey, providerId, next) {
52
+ health.set(`${serviceKey}:${providerId}`, next);
53
+ }
54
+ };
55
+ }
56
+ function defineProvider(spec) {
57
+ return spec;
58
+ }
59
+ var STRIKES_TO_OFFLINE = 3;
60
+ function nextHealth(current, kind) {
61
+ if (kind === "success")
62
+ return { strikes: 0, status: "ok" };
63
+ if (kind === "backoff")
64
+ return current ?? { strikes: 0, status: "ok" };
65
+ const strikes = (current?.strikes ?? 0) + 1;
66
+ return {
67
+ strikes,
68
+ status: strikes >= STRIKES_TO_OFFLINE ? "offline" : "degraded",
69
+ lastFailureAtTs: Date.now()
70
+ };
71
+ }
72
+ function createRegistry(options) {
73
+ const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
74
+ async function call(serviceKey, args) {
75
+ const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
76
+ const attempts = [];
77
+ for (const entry of configured) {
78
+ const spec = byId.get(entry.providerId);
79
+ if (!spec) {
80
+ attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
81
+ continue;
82
+ }
83
+ if (entry.health?.status === "offline") {
84
+ attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
85
+ continue;
86
+ }
87
+ options.before?.({ service: serviceKey, provider: entry.providerId });
88
+ try {
89
+ const result = await spec.invoke({
90
+ config: entry.config,
91
+ secret: (field) => options.credentials.get(entry.secretRef, field)
92
+ }, args);
93
+ attempts.push({ providerId: entry.providerId, outcome: "sent" });
94
+ await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
95
+ const sent = { ok: true, result, provider: entry.providerId, attempts };
96
+ options.after?.(sent);
97
+ return sent;
98
+ } catch (error) {
99
+ const kind = spec.classify(error);
100
+ attempts.push({
101
+ providerId: entry.providerId,
102
+ outcome: "failed",
103
+ kind,
104
+ error: error instanceof Error ? error.message : String(error)
105
+ });
106
+ await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
107
+ if (kind === "terminal") {
108
+ const refused = {
109
+ ok: false,
110
+ attempts,
111
+ error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
112
+ };
113
+ options.after?.(refused);
114
+ return refused;
115
+ }
116
+ }
117
+ }
118
+ const failed = {
119
+ ok: false,
120
+ attempts,
121
+ error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
122
+ };
123
+ options.after?.(failed);
124
+ return failed;
125
+ }
126
+ return { call };
127
+ }
128
+ var VERSION = "0.1.0";
129
+
130
+ // src/http.ts
131
+ class BudgetExhausted extends ProviderError {
132
+ host;
133
+ retryAfterMs;
134
+ constructor(host, retryAfterMs) {
135
+ super("RATE_BUDGET_EXHAUSTED", `The ${host} budget is spent. Retry in ${retryAfterMs}ms.`);
136
+ this.host = host;
137
+ this.retryAfterMs = retryAfterMs;
138
+ }
139
+ }
140
+ var windows = new Map;
141
+ function resetBudgets() {
142
+ windows.clear();
143
+ }
144
+ function spend(budget, cost, nowMs) {
145
+ const ceiling = Math.floor(budget.limit * (budget.headroom ?? 0.9));
146
+ const current = windows.get(budget.host);
147
+ if (!current || current.resetAtMs <= nowMs) {
148
+ windows.set(budget.host, { spent: cost, resetAtMs: nowMs + budget.windowMs });
149
+ return;
150
+ }
151
+ if (current.spent + cost > ceiling) {
152
+ throw new BudgetExhausted(budget.host, current.resetAtMs - nowMs);
153
+ }
154
+ current.spent += cost;
155
+ }
156
+ function settle(host, reserved, actual) {
157
+ const current = windows.get(host);
158
+ if (!current)
159
+ return;
160
+ current.spent = Math.max(0, current.spent - reserved + actual);
161
+ }
162
+ var budgetState = (host) => windows.get(host);
163
+ function createHttpClient(config) {
164
+ const doFetch = config.fetch ?? globalThis.fetch;
165
+ const timeoutMs = config.timeoutMs ?? 1e4;
166
+ return {
167
+ async call(request) {
168
+ const reserved = request.weight ?? config.budget.defaultCost;
169
+ spend(config.budget, reserved, Date.now());
170
+ const url = new URL(config.baseUrl + request.path);
171
+ for (const [key, value] of Object.entries(request.query ?? {})) {
172
+ url.searchParams.set(key, String(value));
173
+ }
174
+ const controller = new AbortController;
175
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
176
+ try {
177
+ const response = await doFetch(url.toString(), {
178
+ method: request.method ?? "GET",
179
+ signal: controller.signal,
180
+ headers: {
181
+ ...request.body === undefined ? {} : { "content-type": "application/json" },
182
+ ...request.headers
183
+ },
184
+ ...request.body === undefined ? {} : { body: JSON.stringify(request.body) }
185
+ });
186
+ const cost = config.costOf?.(response);
187
+ if (cost !== undefined)
188
+ settle(config.budget.host, reserved, cost);
189
+ const text = await response.text();
190
+ let body;
191
+ try {
192
+ body = text ? JSON.parse(text) : null;
193
+ } catch {
194
+ body = text;
195
+ }
196
+ if (!response.ok) {
197
+ throw Object.assign(new Error(`${config.budget.host} ${response.status}`), {
198
+ status: response.status,
199
+ body,
200
+ retryAfter: response.headers.get("retry-after")
201
+ });
202
+ }
203
+ return { status: response.status, body, cost };
204
+ } finally {
205
+ clearTimeout(timer);
206
+ }
207
+ },
208
+ budget: () => budgetState(config.budget.host)
209
+ };
210
+ }
211
+ var http = defineProvider({
212
+ id: "http",
213
+ service: "http",
214
+ label: "HTTP",
215
+ multiInstance: true,
216
+ credentials: {
217
+ type: "object",
218
+ additionalProperties: false,
219
+ properties: {
220
+ apiKey: { type: "string", title: "API key", writeOnly: true },
221
+ apiSecret: { type: "string", title: "API secret", writeOnly: true }
222
+ }
223
+ },
224
+ config: {
225
+ type: "object",
226
+ additionalProperties: false,
227
+ required: ["baseUrl"],
228
+ properties: {
229
+ baseUrl: { type: "string", title: "Base URL" },
230
+ limit: { type: "integer", default: 6000, title: "Units per window" },
231
+ windowMs: { type: "integer", default: 60000 },
232
+ headroom: {
233
+ type: "number",
234
+ default: 0.9,
235
+ description: "Stop at this fraction of the limit. The venue's window boundary is not ours, and the penalty for crossing is a ban rather than a rejection."
236
+ }
237
+ }
238
+ },
239
+ async invoke(context, request) {
240
+ const config = context.config;
241
+ const client = createHttpClient({
242
+ baseUrl: config.baseUrl,
243
+ fetch: config.fetch,
244
+ costOf: config.costOf,
245
+ budget: {
246
+ host: new URL(config.baseUrl).host,
247
+ limit: config.limit ?? 6000,
248
+ windowMs: config.windowMs ?? 60000,
249
+ defaultCost: 1,
250
+ headroom: config.headroom
251
+ }
252
+ });
253
+ return client.call(request);
254
+ },
255
+ classify(error) {
256
+ const status = error.status;
257
+ if (status === 418)
258
+ return "backoff";
259
+ if (status === 429)
260
+ return "backoff";
261
+ if (status === 400 || status === 422)
262
+ return "terminal";
263
+ if (status === 401 || status === 403)
264
+ return "retryable";
265
+ return "retryable";
266
+ }
267
+ });
268
+ var binanceWeight = (response) => {
269
+ const header = response.headers.get("x-mbx-used-weight-1m");
270
+ return header === null ? undefined : Number(header);
271
+ };
272
+ var httpProviders = [http];
273
+
274
+ // src/binance.ts
275
+ import { parseAmount, formatAmount, zero, assetSpec } from "@forgezero/runtime/finance/money";
276
+ import {
277
+ parseSymbol,
278
+ VenueError
279
+ } from "@forgezero/runtime/finance/venues";
280
+ var BUDGET_HOST = "binance";
281
+ var DEFAULT_HOSTS = {
282
+ spot: "https://api.binance.com",
283
+ margin: "https://api.binance.com",
284
+ futures: "https://fapi.binance.com"
285
+ };
286
+ var binanceSymbol = (symbol) => {
287
+ const { base, quote } = parseSymbol(symbol);
288
+ return `${base}${quote}`;
289
+ };
290
+ var PATHS = {
291
+ spot: {
292
+ order: "/api/v3/order",
293
+ openOrders: "/api/v3/openOrders",
294
+ account: "/api/v3/account",
295
+ exchangeInfo: "/api/v3/exchangeInfo"
296
+ },
297
+ margin: {
298
+ order: "/sapi/v1/margin/order",
299
+ openOrders: "/sapi/v1/margin/openOrders",
300
+ account: "/sapi/v1/margin/account",
301
+ exchangeInfo: "/api/v3/exchangeInfo"
302
+ },
303
+ futures: {
304
+ order: "/fapi/v1/order",
305
+ openOrders: "/fapi/v1/openOrders",
306
+ account: "/fapi/v2/account",
307
+ exchangeInfo: "/fapi/v1/exchangeInfo"
308
+ }
309
+ };
310
+ function toOrderStatus(status) {
311
+ switch (status) {
312
+ case "NEW":
313
+ case "PENDING_NEW":
314
+ return "accepted";
315
+ case "PARTIALLY_FILLED":
316
+ return "partial";
317
+ case "FILLED":
318
+ return "filled";
319
+ case "CANCELED":
320
+ case "PENDING_CANCEL":
321
+ case "EXPIRED":
322
+ case "EXPIRED_IN_MATCH":
323
+ return "cancelled";
324
+ default:
325
+ return "rejected";
326
+ }
327
+ }
328
+ var SIDE = { buy: "BUY", sell: "SELL" };
329
+ var TYPE = { market: "MARKET", limit: "LIMIT", "stop-limit": "STOP_LOSS_LIMIT" };
330
+ async function sign(secret, query) {
331
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
332
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(query));
333
+ return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
334
+ }
335
+ function createBinanceAdapter(options) {
336
+ const hosts = { ...DEFAULT_HOSTS, ...options.hosts };
337
+ const recvWindow = options.recvWindowMs ?? 5000;
338
+ const now = options.now ?? Date.now;
339
+ const clients = new Map;
340
+ const clientFor = (type) => {
341
+ const base = hosts[type];
342
+ const existing = clients.get(base);
343
+ if (existing)
344
+ return existing;
345
+ const client = createHttpClient({
346
+ baseUrl: base,
347
+ fetch: options.fetch,
348
+ costOf: binanceWeight,
349
+ budget: { host: BUDGET_HOST, limit: 6000, windowMs: 60000, defaultCost: 1, headroom: 0.9 }
350
+ });
351
+ clients.set(base, client);
352
+ return client;
353
+ };
354
+ async function signed(args) {
355
+ const entries = Object.entries(args.params).filter(([, value]) => value !== undefined);
356
+ const base = new URLSearchParams(entries.map(([name, value]) => [name, String(value)])).toString();
357
+ const withTiming = `${base}${base ? "&" : ""}recvWindow=${recvWindow}&timestamp=${now()}`;
358
+ const signature = await sign(options.credentials.apiSecret, withTiming);
359
+ const response = await clientFor(args.type).call({
360
+ path: `${args.path}?${withTiming}&signature=${signature}`,
361
+ method: args.method,
362
+ weight: args.weight,
363
+ headers: { "X-MBX-APIKEY": options.credentials.apiKey }
364
+ });
365
+ return response.body;
366
+ }
367
+ return {
368
+ venue: "binance",
369
+ symbolFor: (symbol) => binanceSymbol(symbol),
370
+ async markets(type) {
371
+ const info = await clientFor(type).call({ path: PATHS[type].exchangeInfo, weight: 20 });
372
+ return (info.body.symbols ?? []).filter((entry) => entry.status === "TRADING").map((entry) => {
373
+ const filter = (name) => entry.filters.find((candidate) => candidate.filterType === name);
374
+ return {
375
+ venue: "binance",
376
+ type,
377
+ symbol: `${entry.baseAsset}/${entry.quoteAsset}`,
378
+ base: entry.baseAsset,
379
+ quote: entry.quoteAsset,
380
+ lotStep: filter("LOT_SIZE")?.stepSize ?? "0.00000001",
381
+ tickStep: filter("PRICE_FILTER")?.tickSize ?? "0.00000001",
382
+ minNotional: filter("NOTIONAL")?.minNotional ?? filter("MIN_NOTIONAL")?.minNotional ?? "0",
383
+ ...type === "spot" ? {} : { maxLeverage: 125 }
384
+ };
385
+ });
386
+ },
387
+ async placeOrder(request, market) {
388
+ if (request.type === "futures" && request.leverage) {
389
+ await signed({
390
+ type: "futures",
391
+ path: "/fapi/v1/leverage",
392
+ method: "POST",
393
+ params: { symbol: binanceSymbol(request.symbol), leverage: request.leverage }
394
+ });
395
+ }
396
+ const raw = await signed({
397
+ type: request.type,
398
+ path: PATHS[request.type].order,
399
+ method: "POST",
400
+ weight: 1,
401
+ params: {
402
+ symbol: binanceSymbol(request.symbol),
403
+ side: SIDE[request.side],
404
+ type: TYPE[request.orderType],
405
+ quantity: formatAmount(request.quantity, { trim: true }),
406
+ price: request.price ? formatAmount(request.price, { trim: true }) : undefined,
407
+ stopPrice: request.stopPrice ? formatAmount(request.stopPrice, { trim: true }) : undefined,
408
+ timeInForce: request.orderType === "market" ? undefined : (request.timeInForce ?? "gtc").toUpperCase(),
409
+ newClientOrderId: request.clientOrderId,
410
+ ...request.type === "margin" ? { sideEffectType: "NO_SIDE_EFFECT" } : {},
411
+ ...request.dryRun ? { test: "true" } : {}
412
+ }
413
+ });
414
+ return readOrder(raw, market);
415
+ },
416
+ async cancelOrder(args) {
417
+ await signed({
418
+ type: args.type,
419
+ path: PATHS[args.type].order,
420
+ method: "DELETE",
421
+ params: { symbol: binanceSymbol(args.symbol), orderId: args.venueOrderId }
422
+ });
423
+ },
424
+ async openOrders(args) {
425
+ const raw = await signed({
426
+ type: args.type,
427
+ path: PATHS[args.type].openOrders,
428
+ method: "GET",
429
+ weight: args.symbol ? 3 : 40,
430
+ params: { symbol: args.symbol ? binanceSymbol(args.symbol) : undefined }
431
+ });
432
+ return (raw ?? []).map((entry) => readOrder(entry, undefined));
433
+ },
434
+ async balances(type) {
435
+ const raw = await signed({ type, path: PATHS[type].account, method: "GET", weight: 10, params: {} });
436
+ const rows = raw.balances ?? raw.userAssets ?? (raw.assets ?? []).map((entry) => ({ asset: entry.asset, free: entry.availableBalance }));
437
+ return rows.filter((entry) => Number(entry.free) > 0).map((entry) => {
438
+ try {
439
+ return parseAmount(entry.free, entry.asset);
440
+ } catch {
441
+ return null;
442
+ }
443
+ }).filter((amount) => amount !== null);
444
+ }
445
+ };
446
+ }
447
+ function readOrder(raw, market) {
448
+ const asset = market?.base ?? "BTC";
449
+ const executed = String(raw.executedQty ?? raw.origQty ?? "0");
450
+ let filledQuantity;
451
+ try {
452
+ filledQuantity = parseAmount(executed, asset);
453
+ } catch {
454
+ filledQuantity = zero(asset);
455
+ }
456
+ const quoteFilled = Number(raw.cummulativeQuoteQty ?? 0);
457
+ const filled = Number(executed);
458
+ return {
459
+ venueOrderId: String(raw.orderId ?? ""),
460
+ clientOrderId: raw.clientOrderId ? String(raw.clientOrderId) : undefined,
461
+ status: toOrderStatus(String(raw.status ?? "NEW")),
462
+ filledQuantity,
463
+ ...market && filled > 0 && quoteFilled > 0 ? {
464
+ averagePrice: (() => {
465
+ try {
466
+ return parseAmount((quoteFilled / filled).toFixed(assetSpec(market.quote).decimals), market.quote);
467
+ } catch {
468
+ return;
469
+ }
470
+ })()
471
+ } : {},
472
+ raw
473
+ };
474
+ }
475
+ function readBinanceError(error) {
476
+ const body = error.body;
477
+ if (!body?.code)
478
+ return;
479
+ const known = {
480
+ [-1013]: ["MIN_NOTIONAL", "The order is below the venue minimum, or off its lot or tick step."],
481
+ [-2010]: ["MIN_NOTIONAL", "Rejected: insufficient balance, or below the minimum."],
482
+ [-1111]: ["LOT_STEP", "More decimal places than this market accepts."],
483
+ [-1121]: ["UNKNOWN_MARKET", "That symbol is not traded on this venue."]
484
+ };
485
+ const match = known[body.code];
486
+ return match ? new VenueError(match[0], `${match[1]} (${body.msg})`) : undefined;
487
+ }
488
+ export {
489
+ toOrderStatus,
490
+ readBinanceError,
491
+ createBinanceAdapter,
492
+ binanceSymbol
493
+ };