@qbit-oss/api 0.0.0-stage → 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 +21 -0
- package/README.md +467 -2
- package/lib/index.d.mts +551 -0
- package/lib/index.d.ts +551 -0
- package/lib/index.js +1921 -0
- package/lib/index.js.map +1 -0
- package/lib/index.mjs +1889 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +61 -4
- package/src/index.ts +3069 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,1889 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
3
|
+
var unredactedCauses = /* @__PURE__ */ new WeakMap();
|
|
4
|
+
var QBIT_API_ERROR_BRAND = /* @__PURE__ */ Symbol.for("qbit-js.QbitApiError");
|
|
5
|
+
var QbitApiError = class extends Error {
|
|
6
|
+
/**
|
|
7
|
+
* The underlying error of `error` as the fetch implementation threw it, or
|
|
8
|
+
* undefined. Its message, stack and properties can contain the full request
|
|
9
|
+
* URL, base-URL secrets included, so it is kept off the error object, where
|
|
10
|
+
* no logger, error reporter or serializer can reach it. Do not log it where
|
|
11
|
+
* the base URL is secret.
|
|
12
|
+
*/
|
|
13
|
+
static unredactedCause(error) {
|
|
14
|
+
return typeof error === "object" && error !== null ? unredactedCauses.get(error) : void 0;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether `value` is a `QbitApiError` (or a subclass instance) from any copy
|
|
18
|
+
* of `@qbit-oss/api`. `instanceof` only recognizes errors from the same copy,
|
|
19
|
+
* so it is false when an app loads both the CommonJS and the ESM build (or
|
|
20
|
+
* two installed versions) and the error comes from the other one. This checks
|
|
21
|
+
* a brand, `Symbol.for('qbit-js.QbitApiError')`, set on the class prototype
|
|
22
|
+
* instead. It identifies the class, not where the error came from: any code
|
|
23
|
+
* can set the brand. An error from another version can carry a `kind` this
|
|
24
|
+
* version does not list, so keep a default branch when switching on it.
|
|
25
|
+
*/
|
|
26
|
+
static is(value) {
|
|
27
|
+
return typeof value === "object" && value !== null && value[QBIT_API_ERROR_BRAND] === true;
|
|
28
|
+
}
|
|
29
|
+
constructor(kind, message, options = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "QbitApiError";
|
|
32
|
+
this.kind = kind;
|
|
33
|
+
if (options.status !== void 0) this.status = options.status;
|
|
34
|
+
if (options.url !== void 0) this.url = options.url;
|
|
35
|
+
if (options.retryAfterMs !== void 0) this.retryAfterMs = options.retryAfterMs;
|
|
36
|
+
if (options.cause !== void 0) this.cause = options.cause;
|
|
37
|
+
if (options.unredactedCause !== void 0) unredactedCauses.set(this, options.unredactedCause);
|
|
38
|
+
if (options.responseBody !== void 0) this.responseBody = options.responseBody;
|
|
39
|
+
if (options.txid !== void 0) this.txid = options.txid;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
Object.defineProperty(QbitApiError.prototype, QBIT_API_ERROR_BRAND, { value: true });
|
|
43
|
+
var TRANSIENT_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
44
|
+
var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
|
|
45
|
+
var UNPROCESSED_SERVER_ERROR_STATUSES = /* @__PURE__ */ new Set([501, 505, 511]);
|
|
46
|
+
var PRE_CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
47
|
+
var CONNECT_STAGE_ERROR_CODES = /* @__PURE__ */ new Set(["EHOSTUNREACH", "ENETUNREACH"]);
|
|
48
|
+
var TRANSPORT_ERROR_CODE_PATTERN = /^(?:E[A-Z0-9_]+|UND_ERR_[A-Z0-9_]+|HPE_[A-Z0-9_]+)$/;
|
|
49
|
+
var TXID_PATTERN = /^[0-9a-f]{64}$/;
|
|
50
|
+
var DEFAULT_MAX_ADDRESS_HISTORY_PAGES = 256;
|
|
51
|
+
var DEFAULT_MAX_ADDRESS_HISTORY_TRANSACTIONS = 6400;
|
|
52
|
+
var DEFAULT_MAX_RETRY_AFTER_MS = 3e4;
|
|
53
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
54
|
+
var MAX_RETRIES = 10;
|
|
55
|
+
var UNSAFE_BASE_URL_CHAR = /[\s\p{Cc}\\?#]|(?![\u200c\u200d])[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
|
|
56
|
+
function validBaseUrl(value, allowInsecureHttp) {
|
|
57
|
+
const invalid = () => new QbitApiError(
|
|
58
|
+
"http",
|
|
59
|
+
"baseUrl must be an absolute http(s) URL without whitespace, control or format characters, backslashes, credentials, query or fragment"
|
|
60
|
+
);
|
|
61
|
+
if (typeof value !== "string" || UNSAFE_BASE_URL_CHAR.test(value)) throw invalid();
|
|
62
|
+
if (followsUrlStandard() && standardHref(value) === void 0) throw invalid();
|
|
63
|
+
const url = schemeAndHost(value);
|
|
64
|
+
if (!url || url.protocol !== "http:" && url.protocol !== "https:" || !url.hostname || url.userinfo || !/^\d*$/.test(url.port) || Number(url.port) > 65535 || /%E2%80%8[CD]|[\u200c\u200d]/i.test(url.path)) {
|
|
65
|
+
throw invalid();
|
|
66
|
+
}
|
|
67
|
+
if (url.protocol === "http:" && allowInsecureHttp !== true && !isLoopbackHostname(url.hostname)) {
|
|
68
|
+
throw new QbitApiError(
|
|
69
|
+
"http",
|
|
70
|
+
"baseUrl must use https unless its host is loopback; pass allowInsecureHttp: true to allow plain http"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return value.replace(/\/+$/, "");
|
|
74
|
+
}
|
|
75
|
+
function integerOption(value, defaultValue, name, min, max) {
|
|
76
|
+
const option = value === void 0 ? defaultValue : value;
|
|
77
|
+
if (!Number.isInteger(option) || option < min || option > max) {
|
|
78
|
+
throw new QbitApiError("http", `${name} must be an integer from ${min} to ${max}`);
|
|
79
|
+
}
|
|
80
|
+
return option;
|
|
81
|
+
}
|
|
82
|
+
function retryBaseDelayOption(value) {
|
|
83
|
+
const delay = value === void 0 ? 300 : value;
|
|
84
|
+
if (typeof delay !== "number" || !Number.isFinite(delay) || delay < 0 || delay > MAX_TIMER_DELAY_MS) {
|
|
85
|
+
throw new QbitApiError(
|
|
86
|
+
"http",
|
|
87
|
+
`retryBaseDelayMs must be a finite number from 0 to ${MAX_TIMER_DELAY_MS}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return delay;
|
|
91
|
+
}
|
|
92
|
+
function defaultSleep(ms, signal) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
if (signal?.aborted) {
|
|
95
|
+
reject(abortErrorFor(signal));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const onAbort = () => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
reject(abortErrorFor(signal));
|
|
101
|
+
};
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
signal?.removeEventListener("abort", onAbort);
|
|
104
|
+
resolve();
|
|
105
|
+
}, ms);
|
|
106
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
var internalSignals = /* @__PURE__ */ new WeakSet();
|
|
110
|
+
function internalAbortController() {
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
internalSignals.add(controller.signal);
|
|
113
|
+
return controller;
|
|
114
|
+
}
|
|
115
|
+
function abortErrorFor(signal) {
|
|
116
|
+
const reason = signal.reason;
|
|
117
|
+
if (internalSignals.has(signal) && reason instanceof QbitApiError) return reason;
|
|
118
|
+
return new QbitApiError("aborted", `request aborted by caller${formatErrorCause(reason)}`, {
|
|
119
|
+
cause: reason
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function openOperationScope(sources, deadlineMs) {
|
|
123
|
+
if (deadlineMs !== void 0 && !(Number.isFinite(deadlineMs) && deadlineMs > 0 && deadlineMs <= MAX_TIMER_DELAY_MS)) {
|
|
124
|
+
throw new QbitApiError(
|
|
125
|
+
"http",
|
|
126
|
+
`deadlineMs must be a positive number no greater than ${MAX_TIMER_DELAY_MS}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const controller = internalAbortController();
|
|
130
|
+
const cleanups = [];
|
|
131
|
+
const dispose = () => {
|
|
132
|
+
for (const cleanup of cleanups) cleanup();
|
|
133
|
+
cleanups.length = 0;
|
|
134
|
+
};
|
|
135
|
+
const abort = (error) => {
|
|
136
|
+
if (controller.signal.aborted) return;
|
|
137
|
+
controller.abort(error);
|
|
138
|
+
dispose();
|
|
139
|
+
};
|
|
140
|
+
for (const source of sources) {
|
|
141
|
+
if (source === void 0) continue;
|
|
142
|
+
if (source.aborted) {
|
|
143
|
+
abort(abortErrorFor(source));
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
const onAbort = () => abort(abortErrorFor(source));
|
|
147
|
+
source.addEventListener("abort", onAbort, { once: true });
|
|
148
|
+
cleanups.push(() => source.removeEventListener("abort", onAbort));
|
|
149
|
+
}
|
|
150
|
+
if (deadlineMs !== void 0 && !controller.signal.aborted) {
|
|
151
|
+
const timer = setTimeout(
|
|
152
|
+
() => abort(new QbitApiError("timeout", `operation exceeded its ${deadlineMs}ms deadline`)),
|
|
153
|
+
deadlineMs
|
|
154
|
+
);
|
|
155
|
+
cleanups.push(() => clearTimeout(timer));
|
|
156
|
+
}
|
|
157
|
+
if (controller.signal.aborted) {
|
|
158
|
+
dispose();
|
|
159
|
+
throw abortErrorFor(controller.signal);
|
|
160
|
+
}
|
|
161
|
+
return { signal: controller.signal, dispose };
|
|
162
|
+
}
|
|
163
|
+
function raceAbort(promise, signal) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const onAbort = () => reject(abortErrorFor(signal));
|
|
166
|
+
if (signal.aborted) onAbort();
|
|
167
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
168
|
+
promise.then(
|
|
169
|
+
(value) => {
|
|
170
|
+
signal.removeEventListener("abort", onAbort);
|
|
171
|
+
resolve(value);
|
|
172
|
+
},
|
|
173
|
+
(error) => {
|
|
174
|
+
signal.removeEventListener("abort", onAbort);
|
|
175
|
+
reject(error);
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
var EsploraClient = class {
|
|
181
|
+
constructor(options) {
|
|
182
|
+
/** In-flight GET de-dup: concurrent identical GETs share one request. */
|
|
183
|
+
this.inflight = /* @__PURE__ */ new Map();
|
|
184
|
+
if (!options.baseUrl) {
|
|
185
|
+
throw new QbitApiError("http", "EsploraClient requires a baseUrl");
|
|
186
|
+
}
|
|
187
|
+
const baseUrl = validBaseUrl(options.baseUrl, options.allowInsecureHttp);
|
|
188
|
+
const timeoutMs = integerOption(options.timeoutMs, 15e3, "timeoutMs", 1, MAX_TIMER_DELAY_MS);
|
|
189
|
+
const retries = integerOption(options.retries, 2, "retries", 0, MAX_RETRIES);
|
|
190
|
+
const retryBaseDelayMs = retryBaseDelayOption(options.retryBaseDelayMs);
|
|
191
|
+
const maxRetryAfterMs = integerOption(
|
|
192
|
+
options.maxRetryAfterMs,
|
|
193
|
+
DEFAULT_MAX_RETRY_AFTER_MS,
|
|
194
|
+
"maxRetryAfterMs",
|
|
195
|
+
0,
|
|
196
|
+
MAX_TIMER_DELAY_MS
|
|
197
|
+
);
|
|
198
|
+
const fetchImpl = options.fetchFn ?? globalThis.fetch;
|
|
199
|
+
if (typeof fetchImpl !== "function") {
|
|
200
|
+
throw new QbitApiError("network", "no fetch implementation available; pass options.fetchFn");
|
|
201
|
+
}
|
|
202
|
+
this.baseUrl = baseUrl;
|
|
203
|
+
this.fetchFn = options.fetchFn ? fetchImpl : fetchImpl.bind(globalThis);
|
|
204
|
+
this.timeoutMs = timeoutMs;
|
|
205
|
+
this.retries = retries;
|
|
206
|
+
this.retryBaseDelayMs = retryBaseDelayMs;
|
|
207
|
+
this.maxRetryAfterMs = maxRetryAfterMs;
|
|
208
|
+
this.headers = { ...options.headers ?? {} };
|
|
209
|
+
this.credentials = options.credentials ?? "omit";
|
|
210
|
+
this.sleepFn = options.sleepFn ?? defaultSleep;
|
|
211
|
+
this.signal = options.signal;
|
|
212
|
+
}
|
|
213
|
+
// --- esplora endpoints -------------------------------------------------
|
|
214
|
+
getAddressStats(address, options) {
|
|
215
|
+
return this.getJson(`/address/${encodeURIComponent(address)}`, options);
|
|
216
|
+
}
|
|
217
|
+
getAddressUtxos(address, options) {
|
|
218
|
+
return this.getJson(`/address/${encodeURIComponent(address)}/utxo`, options);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Confirmed transaction history for an address, newest first, 25 per page.
|
|
222
|
+
* Pass the last txid of the previous page as `afterTxid` to paginate.
|
|
223
|
+
*/
|
|
224
|
+
getAddressTxs(address, afterTxid, options) {
|
|
225
|
+
const suffix = afterTxid ? `/${encodeURIComponent(afterTxid)}` : "";
|
|
226
|
+
return this.getJson(
|
|
227
|
+
`/address/${encodeURIComponent(address)}/txs/chain${suffix}`,
|
|
228
|
+
options
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Unconfirmed (mempool) transactions touching an address. electrs lists at
|
|
233
|
+
* most `--rest-default-max-mempool-txs` (default 50) of them, with no way to
|
|
234
|
+
* page further; `getAddressHistory` detects that cap.
|
|
235
|
+
*/
|
|
236
|
+
getAddressMempoolTxs(address, options) {
|
|
237
|
+
return this.getJson(
|
|
238
|
+
`/address/${encodeURIComponent(address)}/txs/mempool`,
|
|
239
|
+
options
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Complete address history from a bounded, page-one scan. The confirmed
|
|
244
|
+
* chain route is exhausted explicitly; short pages are not assumed final.
|
|
245
|
+
*
|
|
246
|
+
* electrs answers a chain cursor it does not know (a lagging node behind a
|
|
247
|
+
* load balancer, or a reorg between pages) with HTTP 200 and `[]`, and
|
|
248
|
+
* `/txs/mempool` stops at 50 rows, so the scan alone cannot tell a short
|
|
249
|
+
* history from a complete one. The address stats are therefore read before
|
|
250
|
+
* and after the scan, each in a request of its own, and the history is
|
|
251
|
+
* returned only if both reads agree and it holds exactly
|
|
252
|
+
* `chain_stats.tx_count` confirmed and `mempool_stats.tx_count` mempool
|
|
253
|
+
* transactions. If the stats changed during the scan, the address is scanned
|
|
254
|
+
* once more; if they change again, or the counts differ, the call rejects
|
|
255
|
+
* with `invalid_cursor`. Stats without a non-negative safe-integer
|
|
256
|
+
* `tx_count` reject with `parse`. `maxPages` and `maxTransactions` cover
|
|
257
|
+
* both scans.
|
|
258
|
+
*/
|
|
259
|
+
getAddressHistory(address, options = {}) {
|
|
260
|
+
return this.runOperation(options, (signal) => this.scanAddressHistory(address, options, signal));
|
|
261
|
+
}
|
|
262
|
+
async scanAddressHistory(address, options, signal) {
|
|
263
|
+
const maxPages = positiveAddressHistoryLimit(
|
|
264
|
+
options.maxPages,
|
|
265
|
+
DEFAULT_MAX_ADDRESS_HISTORY_PAGES,
|
|
266
|
+
"maxPages"
|
|
267
|
+
);
|
|
268
|
+
const maxTransactions = positiveAddressHistoryLimit(
|
|
269
|
+
options.maxTransactions,
|
|
270
|
+
DEFAULT_MAX_ADDRESS_HISTORY_TRANSACTIONS,
|
|
271
|
+
"maxTransactions"
|
|
272
|
+
);
|
|
273
|
+
const budget = createAddressHistoryBudget();
|
|
274
|
+
return verifiedAddressHistory(
|
|
275
|
+
address,
|
|
276
|
+
"esplora",
|
|
277
|
+
() => this.getJson(`/address/${encodeURIComponent(address)}`, { signal }, false),
|
|
278
|
+
() => this.scanAddressHistoryOnce(address, maxPages, maxTransactions, budget, signal)
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
async scanAddressHistoryOnce(address, maxPages, maxTransactions, budget, signal) {
|
|
282
|
+
if (budget.pageRequests >= maxPages) {
|
|
283
|
+
throw addressHistoryPageLimitError(address, maxPages, "esplora");
|
|
284
|
+
}
|
|
285
|
+
const state = createAddressHistoryScanState();
|
|
286
|
+
const mempoolRows = reserveAddressHistoryTransactions(
|
|
287
|
+
budget,
|
|
288
|
+
await this.getAddressMempoolTxs(address, { signal }),
|
|
289
|
+
maxTransactions,
|
|
290
|
+
address,
|
|
291
|
+
"esplora",
|
|
292
|
+
"esplora mempool"
|
|
293
|
+
);
|
|
294
|
+
const mempoolTxs = assertAddressHistoryPage(mempoolRows, address, "esplora mempool");
|
|
295
|
+
appendAddressHistoryTransactions(state, mempoolTxs, "mempool", address, "esplora");
|
|
296
|
+
let cursor;
|
|
297
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
298
|
+
while (true) {
|
|
299
|
+
budget.pageRequests += 1;
|
|
300
|
+
const rows = reserveAddressHistoryTransactions(
|
|
301
|
+
budget,
|
|
302
|
+
await this.getAddressTxs(address, cursor, { signal }),
|
|
303
|
+
maxTransactions,
|
|
304
|
+
address,
|
|
305
|
+
"esplora",
|
|
306
|
+
"esplora confirmed"
|
|
307
|
+
);
|
|
308
|
+
if (rows.length === 0) break;
|
|
309
|
+
if (budget.pageRequests === maxPages) {
|
|
310
|
+
throw addressHistoryPageLimitError(address, maxPages, "esplora");
|
|
311
|
+
}
|
|
312
|
+
const txs = assertAddressHistoryPage(rows, address, "esplora confirmed");
|
|
313
|
+
cursor = nextAddressHistoryCursor(txs, cursor, seenCursors, address, "esplora");
|
|
314
|
+
appendAddressHistoryTransactions(state, txs, "confirmed", address, "esplora");
|
|
315
|
+
}
|
|
316
|
+
return { confirmed: state.confirmed, mempool: state.mempool };
|
|
317
|
+
}
|
|
318
|
+
getTx(txid, options) {
|
|
319
|
+
return this.getJson(`/tx/${encodeURIComponent(txid)}`, options);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Raw transaction hex as the backend serializes it (with witness data when
|
|
323
|
+
* the transaction has any), in lowercase. The reply is checked before it is
|
|
324
|
+
* returned: it must be non-empty, even-length hex that decodes to one
|
|
325
|
+
* complete transaction whose txid (the double SHA-256 of its
|
|
326
|
+
* witness-stripped serialization, as qbit Core computes it) is `txid`,
|
|
327
|
+
* compared case-insensitively. The transaction must be encoded as qbit
|
|
328
|
+
* Core's decoder requires: every count and length in its shortest
|
|
329
|
+
* CompactSize form and at most 0x02000000, and no witness flag when every
|
|
330
|
+
* witness stack is empty. Anything else, including the empty `200`
|
|
331
|
+
* reply mempool.qbit.org serves, throws a `parse` `QbitApiError`, so a
|
|
332
|
+
* backend cannot hand back an empty string or another transaction. Spaces,
|
|
333
|
+
* tabs and line breaks around the hex are ignored. The txid does not commit
|
|
334
|
+
* to witness data, so a reply with altered or stripped witnesses still
|
|
335
|
+
* passes; compare the wtxid if you need them.
|
|
336
|
+
*/
|
|
337
|
+
async getTxHex(txid, options) {
|
|
338
|
+
const path = `/tx/${encodeURIComponent(txid)}/hex`;
|
|
339
|
+
const text = await this.getText(path, options);
|
|
340
|
+
const hex = trimAsciiWhitespace(text).toLowerCase();
|
|
341
|
+
const invalid = (reason) => new QbitApiError("parse", `transaction hex reply ${reason}`, {
|
|
342
|
+
url: this.errorUrl(path),
|
|
343
|
+
responseBody: responseBodyPreview(text, this.baseUrl)
|
|
344
|
+
});
|
|
345
|
+
if (hex === "") throw invalid("was empty");
|
|
346
|
+
if (hex.length % 2 !== 0 || !/^[0-9a-f]+$/.test(hex)) {
|
|
347
|
+
throw invalid(`was not even-length hex: ${quotedResponseText(text, this.baseUrl)}`);
|
|
348
|
+
}
|
|
349
|
+
const actual = tryTxidFromRawTx(hex);
|
|
350
|
+
if (actual === void 0) throw invalid("is not a well-formed transaction");
|
|
351
|
+
if (actual !== String(txid).toLowerCase()) throw invalid(`is transaction ${actual}, not the one requested`);
|
|
352
|
+
return hex;
|
|
353
|
+
}
|
|
354
|
+
getTxStatus(txid, options) {
|
|
355
|
+
return this.getJson(`/tx/${encodeURIComponent(txid)}/status`, options);
|
|
356
|
+
}
|
|
357
|
+
getFeeEstimates(options) {
|
|
358
|
+
return this.getJson("/fee-estimates", options);
|
|
359
|
+
}
|
|
360
|
+
async getTipHeight(options) {
|
|
361
|
+
const text = await this.getText("/blocks/tip/height", options);
|
|
362
|
+
const height = Number(text.trim());
|
|
363
|
+
if (!Number.isInteger(height)) {
|
|
364
|
+
throw new QbitApiError("parse", `tip height was not an integer: ${quotedResponseText(text, this.baseUrl)}`, {
|
|
365
|
+
url: this.errorUrl("/blocks/tip/height"),
|
|
366
|
+
responseBody: responseBodyPreview(text, this.baseUrl)
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return height;
|
|
370
|
+
}
|
|
371
|
+
async getTipHash(options) {
|
|
372
|
+
return (await this.getText("/blocks/tip/hash", options)).trim();
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Broadcast a raw transaction hex. Resolves with the txid once the node has
|
|
376
|
+
* the transaction, including when it reports the transaction as already in
|
|
377
|
+
* its mempool or chain. A submission is never re-sent after an attempt that
|
|
378
|
+
* may have reached the node: a timeout, an ambiguous network error, or a
|
|
379
|
+
* 5xx reply whose body carries no verdict from the node rejects with
|
|
380
|
+
* `unknown_outcome` carrying the txid so the caller can check
|
|
381
|
+
* `getTxStatus(txid)` before resubmitting the same hex. Only 429 replies and
|
|
382
|
+
* connection failures that happened before anything was sent are retried; a
|
|
383
|
+
* 429 whose `Retry-After` exceeds `maxRetryAfterMs` is not waited on and
|
|
384
|
+
* rejects at once as `rate_limit` with `retryAfterMs`, without a re-send.
|
|
385
|
+
* A redirect is never followed. A refused one (a 3xx, a browser opaque
|
|
386
|
+
* redirect, or a reply from a redirect the runtime followed) rejects with
|
|
387
|
+
* `unknown_outcome` too, with the `http` refusal as `cause`: the POST
|
|
388
|
+
* reached the backend, which may have accepted it before redirecting, and
|
|
389
|
+
* a runtime that followed the redirect may have re-sent it.
|
|
390
|
+
* Coded node rejections fail closed as `http` errors whatever the HTTP
|
|
391
|
+
* status, since qbit Core's own JSON-RPC server answers 500 for every RPC
|
|
392
|
+
* error and a backend or proxy may relay that status. When no txid
|
|
393
|
+
* can be derived from the hex and none is supplied, a 2xx reply resolves only
|
|
394
|
+
* if its body is itself a 64-hex txid; any other 2xx body is `unknown_outcome`.
|
|
395
|
+
* Hex `txidFromRawTx` cannot read, including encodings qbit Core refuses to
|
|
396
|
+
* decode, is still sent as it is, and the node gives the verdict.
|
|
397
|
+
* A malformed `rawTxHex` argument or an `options.txid` that is not a 64-hex
|
|
398
|
+
* transaction id matching the hex throws a `TypeError` before anything is sent.
|
|
399
|
+
*
|
|
400
|
+
* `options.signal` / `options.deadlineMs` bound the whole call. An abort or
|
|
401
|
+
* expired deadline before the POST is sent rejects with `aborted` /
|
|
402
|
+
* `timeout`; once the POST may have been sent (in flight or while its reply
|
|
403
|
+
* is read) it rejects with `unknown_outcome`, with that error as `cause`.
|
|
404
|
+
* A reply whose body stalls past `timeoutMs` or cannot be read is
|
|
405
|
+
* `unknown_outcome` too. A 429 reply is the exception throughout: it proves
|
|
406
|
+
* nothing was submitted, so one whose body is lost is retried (then
|
|
407
|
+
* `rate_limit`), and an abort or deadline during its body is definite.
|
|
408
|
+
*/
|
|
409
|
+
async broadcastTx(rawTxHex, options) {
|
|
410
|
+
return this.submitTx("/tx", rawTxHex, resolveBroadcastTxid(rawTxHex, options), options);
|
|
411
|
+
}
|
|
412
|
+
// --- adapters (clean, wallet-friendly shapes) --------------------------
|
|
413
|
+
/** Confirmed + mempool balance for an address, in satoshis. */
|
|
414
|
+
async getAddressBalance(address, options) {
|
|
415
|
+
return addressBalanceFromStats(await this.getAddressStats(address, options));
|
|
416
|
+
}
|
|
417
|
+
/** Fee-rate tiers (sat/vB), ascending by confirmation target. */
|
|
418
|
+
async getFeeTiers(options) {
|
|
419
|
+
return feeTiersFromEstimates(await this.getFeeEstimates(options));
|
|
420
|
+
}
|
|
421
|
+
// --- internals ---------------------------------------------------------
|
|
422
|
+
/**
|
|
423
|
+
* Run one public call under a single signal that combines the client's
|
|
424
|
+
* signal, the caller's signal, and the caller's deadline. Pass `{ signal }`
|
|
425
|
+
* to dependent calls so they share the same cancellation and budget.
|
|
426
|
+
*/
|
|
427
|
+
async runOperation(options, run) {
|
|
428
|
+
const scope = openOperationScope([this.signal, options?.signal], options?.deadlineMs);
|
|
429
|
+
try {
|
|
430
|
+
return await run(scope.signal);
|
|
431
|
+
} finally {
|
|
432
|
+
scope.dispose();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* GET and parse JSON. With `dedup` false the request is sent on its own
|
|
437
|
+
* instead of joining an identical one already in flight, whose reply may
|
|
438
|
+
* predate requests this call has made since.
|
|
439
|
+
*/
|
|
440
|
+
getJson(path, options, dedup = true) {
|
|
441
|
+
return this.runOperation(options, async (signal) => {
|
|
442
|
+
const response = await this.request(path, { method: "GET", dedup }, { signal });
|
|
443
|
+
const url = this.errorUrl(path);
|
|
444
|
+
const text = await readBody(response, url, this.baseUrl, signal);
|
|
445
|
+
if (!response.ok) throw httpError(response, text, url, this.baseUrl);
|
|
446
|
+
try {
|
|
447
|
+
return JSON.parse(text);
|
|
448
|
+
} catch (cause) {
|
|
449
|
+
throw new QbitApiError("parse", `failed to parse JSON from ${path}`, {
|
|
450
|
+
url,
|
|
451
|
+
responseBody: responseBodyPreview(text, this.baseUrl),
|
|
452
|
+
cause: new SyntaxError("response body is not valid JSON"),
|
|
453
|
+
unredactedCause: cause
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
getText(path, options) {
|
|
459
|
+
return this.runOperation(options, async (signal) => {
|
|
460
|
+
const response = await this.request(path, { method: "GET", dedup: true }, { signal });
|
|
461
|
+
const url = this.errorUrl(path);
|
|
462
|
+
const text = await readBody(response, url, this.baseUrl, signal);
|
|
463
|
+
if (!response.ok) throw httpError(response, text, url, this.baseUrl);
|
|
464
|
+
return text;
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* POST a transaction submission under the submission retry policy and
|
|
469
|
+
* classify the node's reply. `txid` identifies the submission in
|
|
470
|
+
* `unknown_outcome` errors and is the result for already-known replies.
|
|
471
|
+
* An abort or deadline once the POST may have been sent, including while
|
|
472
|
+
* the reply body is read, is an `unknown_outcome` too, unless the reply is
|
|
473
|
+
* a 429.
|
|
474
|
+
*/
|
|
475
|
+
submitTx(path, body, txid, options) {
|
|
476
|
+
return this.runOperation(options, async (signal) => {
|
|
477
|
+
const url = `${this.baseUrl}${path}`;
|
|
478
|
+
const errorUrl = this.errorUrl(path);
|
|
479
|
+
const response = await this.requestWithRetry(
|
|
480
|
+
url,
|
|
481
|
+
{ method: "POST", body, headers: { "content-type": "text/plain" } },
|
|
482
|
+
signal,
|
|
483
|
+
txid
|
|
484
|
+
);
|
|
485
|
+
let text;
|
|
486
|
+
try {
|
|
487
|
+
text = (await readBody(response, errorUrl, this.baseUrl, signal)).trim();
|
|
488
|
+
} catch (cause) {
|
|
489
|
+
if (response.status === 429 && signal.aborted) throw abortErrorFor(signal);
|
|
490
|
+
throw unknownOutcomeError(errorUrl, txid, `HTTP ${response.status} reply body could not be read`, {
|
|
491
|
+
status: response.status,
|
|
492
|
+
cause
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const detail = responseBodyPreview(text, this.baseUrl);
|
|
496
|
+
const reply = httpReplyMessage(response.status, errorUrl, detail);
|
|
497
|
+
if (response.ok) {
|
|
498
|
+
const echoed = text.toLowerCase();
|
|
499
|
+
if (txid === void 0) {
|
|
500
|
+
if (TXID_PATTERN.test(echoed)) return echoed;
|
|
501
|
+
} else if (echoed === txid) {
|
|
502
|
+
return txid;
|
|
503
|
+
}
|
|
504
|
+
const reason = txid === void 0 ? "reply is not a txid" : "reply did not echo the txid";
|
|
505
|
+
throw unknownOutcomeError(errorUrl, txid, `${reason} (${reply})`, {
|
|
506
|
+
status: response.status,
|
|
507
|
+
responseBody: detail
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
if (UNPROCESSED_SERVER_ERROR_STATUSES.has(response.status)) throw httpError(response, text, errorUrl, this.baseUrl);
|
|
511
|
+
const rejection = parseBroadcastReply(text);
|
|
512
|
+
if (isAlreadyKnownBroadcastReply(rejection)) {
|
|
513
|
+
if (txid !== void 0) return txid;
|
|
514
|
+
throw unknownOutcomeError(errorUrl, void 0, `node already knows the transaction: ${reply}`, {
|
|
515
|
+
status: response.status,
|
|
516
|
+
responseBody: detail
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (response.status >= 500) {
|
|
520
|
+
if (isDefiniteNodeRejection(rejection)) throw httpError(response, text, errorUrl, this.baseUrl);
|
|
521
|
+
throw unknownOutcomeError(errorUrl, txid, reply, { status: response.status, responseBody: detail });
|
|
522
|
+
}
|
|
523
|
+
if (isRelayedTransportFailure(rejection)) {
|
|
524
|
+
throw unknownOutcomeError(errorUrl, txid, `backend lost its node connection (${reply})`, {
|
|
525
|
+
status: response.status,
|
|
526
|
+
responseBody: detail
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
throw httpError(response, text, errorUrl, this.baseUrl);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
request(path, init, options) {
|
|
533
|
+
return this.runOperation(options, (signal) => {
|
|
534
|
+
const url = `${this.baseUrl}${path}`;
|
|
535
|
+
return init.dedup ? this.requestShared(url, init, signal) : this.requestWithRetry(url, init, signal);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Concurrent identical GETs share one request. One caller giving up only
|
|
540
|
+
* stops waiting; the shared request is aborted once every caller has left.
|
|
541
|
+
*/
|
|
542
|
+
async requestShared(url, init, signal) {
|
|
543
|
+
let pending = this.inflight.get(url);
|
|
544
|
+
if (pending === void 0) {
|
|
545
|
+
const controller = internalAbortController();
|
|
546
|
+
pending = {
|
|
547
|
+
response: this.requestWithRetry(url, init, controller.signal),
|
|
548
|
+
consumers: 0,
|
|
549
|
+
controller
|
|
550
|
+
};
|
|
551
|
+
this.inflight.set(url, pending);
|
|
552
|
+
}
|
|
553
|
+
pending.consumers += 1;
|
|
554
|
+
let response;
|
|
555
|
+
try {
|
|
556
|
+
response = await raceAbort(pending.response, signal);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
pending.consumers -= 1;
|
|
559
|
+
if (pending.consumers === 0) {
|
|
560
|
+
if (this.inflight.get(url) === pending) this.inflight.delete(url);
|
|
561
|
+
if (signal.aborted) {
|
|
562
|
+
const errorUrl = redactBaseUrl(url, this.baseUrl);
|
|
563
|
+
pending.controller.abort(new QbitApiError("aborted", `request abandoned: ${errorUrl}`, { url: errorUrl }));
|
|
564
|
+
pending.response.then(discardResponseBody, () => void 0);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
pending.consumers -= 1;
|
|
570
|
+
if (pending.consumers === 0) {
|
|
571
|
+
if (this.inflight.get(url) === pending) this.inflight.delete(url);
|
|
572
|
+
return response;
|
|
573
|
+
}
|
|
574
|
+
return response.clone();
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Idempotent requests (GET/HEAD) retry on network errors, timeouts, and
|
|
578
|
+
* transient statuses. Any other method is a submission: an attempt is only
|
|
579
|
+
* repeated when it provably never reached the server (429, or a connection
|
|
580
|
+
* failure before anything was sent), and an attempt that may have been
|
|
581
|
+
* processed fails with `unknown_outcome` instead of being re-sent; that
|
|
582
|
+
* includes an abort or deadline that fires while a submission is in flight,
|
|
583
|
+
* and a refused redirect. `txid` labels that error when the submission is a
|
|
584
|
+
* transaction.
|
|
585
|
+
*/
|
|
586
|
+
async requestWithRetry(url, init, signal, txid) {
|
|
587
|
+
const idempotent = IDEMPOTENT_METHODS.has(init.method.toUpperCase());
|
|
588
|
+
const errorUrl = redactBaseUrl(url, this.baseUrl);
|
|
589
|
+
let lastError;
|
|
590
|
+
for (let attempt = 0; attempt <= this.retries; attempt += 1) {
|
|
591
|
+
if (signal.aborted) throw abortErrorFor(signal);
|
|
592
|
+
const reply = {};
|
|
593
|
+
let response;
|
|
594
|
+
try {
|
|
595
|
+
response = await this.fetchOnce(url, init, signal, reply);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
lastError = QbitApiError.is(error) ? error : new QbitApiError("network", redactBaseUrl(String(error), this.baseUrl), {
|
|
598
|
+
url: errorUrl,
|
|
599
|
+
...causeOptions(error, this.baseUrl)
|
|
600
|
+
});
|
|
601
|
+
if (!idempotent && reply.refusedRedirect) {
|
|
602
|
+
throw unknownOutcomeError(errorUrl, txid, lastError.message, {
|
|
603
|
+
...lastError.status === void 0 ? {} : { status: lastError.status },
|
|
604
|
+
cause: lastError
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
if (!idempotent && reply.status === 429) {
|
|
608
|
+
if (signal.aborted) throw abortErrorFor(signal);
|
|
609
|
+
const { retryAfterMs } = reply;
|
|
610
|
+
if (attempt < this.retries && (retryAfterMs === void 0 || retryAfterMs <= this.maxRetryAfterMs)) {
|
|
611
|
+
await this.sleep(retryAfterMs ?? this.backoffMs(attempt), signal);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
throw new QbitApiError("rate_limit", `HTTP 429 from ${errorUrl} (reply body could not be read)`, {
|
|
615
|
+
status: 429,
|
|
616
|
+
url: errorUrl,
|
|
617
|
+
cause: lastError,
|
|
618
|
+
...retryAfterMs === void 0 ? {} : { retryAfterMs }
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (!idempotent && reply.status !== void 0) {
|
|
622
|
+
throw unknownOutcomeError(errorUrl, txid, `HTTP ${reply.status} reply body could not be read`, {
|
|
623
|
+
status: reply.status,
|
|
624
|
+
cause: lastError
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
if (idempotent && lastError.kind === "parse" && reply.status !== void 0 && TRANSIENT_STATUSES.has(reply.status)) {
|
|
628
|
+
const { retryAfterMs } = reply;
|
|
629
|
+
const overCap = retryAfterMs !== void 0 && retryAfterMs > this.maxRetryAfterMs;
|
|
630
|
+
if (attempt < this.retries && !overCap && !signal.aborted) {
|
|
631
|
+
await this.sleep(retryAfterMs ?? this.backoffMs(attempt), signal);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
throw lastError;
|
|
635
|
+
}
|
|
636
|
+
if (lastError.kind !== "network" && lastError.kind !== "timeout" && lastError.kind !== "aborted") {
|
|
637
|
+
throw lastError;
|
|
638
|
+
}
|
|
639
|
+
const unsent = idempotent || lastError.kind === "network" && lastError instanceof QbitApiError && isPreConnectionFailure(QbitApiError.unredactedCause(lastError) ?? lastError);
|
|
640
|
+
if (!unsent) {
|
|
641
|
+
const reason = redactBaseUrl(String(lastError.message), this.baseUrl);
|
|
642
|
+
throw unknownOutcomeError(errorUrl, txid, reason, { cause: lastError });
|
|
643
|
+
}
|
|
644
|
+
if (lastError.kind !== "aborted" && attempt < this.retries && !signal.aborted) {
|
|
645
|
+
await this.sleep(this.backoffMs(attempt), signal);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
throw lastError;
|
|
649
|
+
}
|
|
650
|
+
const retryable = idempotent ? TRANSIENT_STATUSES.has(response.status) : response.status === 429;
|
|
651
|
+
if (retryable && attempt < this.retries) {
|
|
652
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
653
|
+
if (retryAfterMs !== void 0 && retryAfterMs > this.maxRetryAfterMs) return response;
|
|
654
|
+
void discardResponseBody(response);
|
|
655
|
+
await this.sleep(retryAfterMs ?? this.backoffMs(attempt), signal);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
return response;
|
|
659
|
+
}
|
|
660
|
+
throw lastError ?? new QbitApiError("network", `request failed: ${errorUrl}`, { url: errorUrl });
|
|
661
|
+
}
|
|
662
|
+
/** Backoff that rejects as soon as `signal` aborts, even if `sleepFn` ignores it. */
|
|
663
|
+
async sleep(ms, signal) {
|
|
664
|
+
if (signal.aborted) throw abortErrorFor(signal);
|
|
665
|
+
await raceAbort(
|
|
666
|
+
Promise.resolve().then(() => this.sleepFn(ms, signal)),
|
|
667
|
+
signal
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* One attempt under one timeout that spans the headers and the whole body,
|
|
672
|
+
* also bounded by the call's signal until the body is buffered. Every
|
|
673
|
+
* response it returns is fully buffered, whatever its status, so no caller
|
|
674
|
+
* (retry loop, de-duplicated clone, or error path) can block on the network
|
|
675
|
+
* after the timer is cleared. A body that misses the attempt timeout fails
|
|
676
|
+
* the attempt as `timeout`, even for a status that would otherwise be
|
|
677
|
+
* retried; an abort or call deadline, before or after the headers, rejects
|
|
678
|
+
* with the call's `aborted` / `timeout` error. `reply.status` is set once
|
|
679
|
+
* the headers arrive. A redirect is refused before its body is read, which
|
|
680
|
+
* is cancelled instead, and `reply.refusedRedirect` is set; see
|
|
681
|
+
* `refuseRedirect`.
|
|
682
|
+
*/
|
|
683
|
+
async fetchOnce(url, init, signal, reply) {
|
|
684
|
+
if (signal.aborted) throw abortErrorFor(signal);
|
|
685
|
+
const controller = new AbortController();
|
|
686
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
687
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
688
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
689
|
+
const requestInit = {
|
|
690
|
+
method: init.method,
|
|
691
|
+
headers: { ...this.headers, ...init.headers ?? {} },
|
|
692
|
+
mode: "cors",
|
|
693
|
+
redirect: "manual",
|
|
694
|
+
credentials: this.credentials,
|
|
695
|
+
signal: controller.signal
|
|
696
|
+
};
|
|
697
|
+
if (init.body !== void 0) requestInit.body = init.body;
|
|
698
|
+
let pending;
|
|
699
|
+
try {
|
|
700
|
+
let response;
|
|
701
|
+
try {
|
|
702
|
+
pending = this.fetchFn(url, requestInit);
|
|
703
|
+
response = await raceAbort(pending, signal);
|
|
704
|
+
} catch (cause) {
|
|
705
|
+
if (signal.aborted) {
|
|
706
|
+
pending?.then(discardResponseBody, () => void 0);
|
|
707
|
+
throw abortErrorFor(signal);
|
|
708
|
+
}
|
|
709
|
+
if (QbitApiError.is(cause)) throw cause;
|
|
710
|
+
if (isAbortError(cause)) throw this.timeoutError(url, cause);
|
|
711
|
+
const errorUrl = redactBaseUrl(url, this.baseUrl);
|
|
712
|
+
throw new QbitApiError("network", `network error: ${errorUrl}${redactBaseUrl(formatErrorCause(cause), this.baseUrl)}`, {
|
|
713
|
+
url: errorUrl,
|
|
714
|
+
...causeOptions(cause, this.baseUrl)
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
refuseRedirect(response, url, this.baseUrl);
|
|
719
|
+
} catch (refusal) {
|
|
720
|
+
reply.refusedRedirect = true;
|
|
721
|
+
throw refusal;
|
|
722
|
+
}
|
|
723
|
+
reply.status = response.status;
|
|
724
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
725
|
+
if (retryAfterMs !== void 0) reply.retryAfterMs = retryAfterMs;
|
|
726
|
+
let text;
|
|
727
|
+
try {
|
|
728
|
+
text = await readBodyUntilAborted(response, controller.signal);
|
|
729
|
+
} catch (cause) {
|
|
730
|
+
if (signal.aborted) throw abortErrorFor(signal);
|
|
731
|
+
if (controller.signal.aborted) throw this.timeoutError(url, cause, response.status);
|
|
732
|
+
throw bodyReadError(response, cause, redactBaseUrl(url, this.baseUrl), this.baseUrl);
|
|
733
|
+
}
|
|
734
|
+
return bufferedResponse(response, text);
|
|
735
|
+
} finally {
|
|
736
|
+
clearTimeout(timer);
|
|
737
|
+
signal.removeEventListener("abort", onAbort);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** `status` is set when the headers arrived and the body missed the deadline. */
|
|
741
|
+
timeoutError(url, cause, status) {
|
|
742
|
+
const errorUrl = redactBaseUrl(url, this.baseUrl);
|
|
743
|
+
return new QbitApiError(
|
|
744
|
+
"timeout",
|
|
745
|
+
`request timed out after ${this.timeoutMs}ms: ${errorUrl}${redactBaseUrl(formatErrorCause(cause), this.baseUrl)}`,
|
|
746
|
+
{ url: errorUrl, ...causeOptions(cause, this.baseUrl), ...status === void 0 ? {} : { status } }
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
backoffMs(attempt) {
|
|
750
|
+
return Math.min(this.retryBaseDelayMs * 2 ** attempt, MAX_TIMER_DELAY_MS);
|
|
751
|
+
}
|
|
752
|
+
/** The request URL for `path` as errors show it: see `redactedBaseUrl`. */
|
|
753
|
+
errorUrl(path) {
|
|
754
|
+
return `${redactedBaseUrl(this.baseUrl)}${path}`;
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
var MEMPOOL_QBIT_ADDRESS_PAGE_SIZE = 10;
|
|
758
|
+
var MempoolQbitClient = class extends EsploraClient {
|
|
759
|
+
constructor(options) {
|
|
760
|
+
super(options);
|
|
761
|
+
const maxAddressHistoryPages = options.maxAddressHistoryPages ?? DEFAULT_MAX_ADDRESS_HISTORY_PAGES;
|
|
762
|
+
if (!Number.isSafeInteger(maxAddressHistoryPages) || maxAddressHistoryPages < 1) {
|
|
763
|
+
throw new QbitApiError("http", "maxAddressHistoryPages must be a positive integer");
|
|
764
|
+
}
|
|
765
|
+
this.maxAddressHistoryPages = maxAddressHistoryPages;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Complete address history from one bounded traversal of mempool-qbit's
|
|
769
|
+
* combined endpoint. Page size is intentionally not assumed.
|
|
770
|
+
*
|
|
771
|
+
* The result is checked against the address stats, read before and after
|
|
772
|
+
* the traversal, as `EsploraClient.getAddressHistory` describes, and a
|
|
773
|
+
* traversal during which they changed is repeated once. qbit-electrs
|
|
774
|
+
* answers a cursor that is the last mempool row with `[]` instead of the
|
|
775
|
+
* confirmed rows that follow, so an address whose mempool rows fill whole
|
|
776
|
+
* pages (50 each by default) rejects with `invalid_cursor` while it has
|
|
777
|
+
* confirmed history, rather than returning none.
|
|
778
|
+
*/
|
|
779
|
+
getAddressHistory(address, options = {}) {
|
|
780
|
+
return this.runOperation(
|
|
781
|
+
options,
|
|
782
|
+
(signal) => this.scanCombinedAddressHistory(address, options, signal)
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
async scanCombinedAddressHistory(address, options, signal) {
|
|
786
|
+
const requestedMaxPages = positiveAddressHistoryLimit(
|
|
787
|
+
options.maxPages,
|
|
788
|
+
this.maxAddressHistoryPages,
|
|
789
|
+
"maxPages"
|
|
790
|
+
);
|
|
791
|
+
const maxPages = Math.min(requestedMaxPages, this.maxAddressHistoryPages);
|
|
792
|
+
const maxTransactions = positiveAddressHistoryLimit(
|
|
793
|
+
options.maxTransactions,
|
|
794
|
+
DEFAULT_MAX_ADDRESS_HISTORY_TRANSACTIONS,
|
|
795
|
+
"maxTransactions"
|
|
796
|
+
);
|
|
797
|
+
const budget = createAddressHistoryBudget();
|
|
798
|
+
return verifiedAddressHistory(
|
|
799
|
+
address,
|
|
800
|
+
"mempool-qbit",
|
|
801
|
+
() => this.getJson(`/address/${encodeURIComponent(address)}`, { signal }, false),
|
|
802
|
+
() => this.scanCombinedAddressHistoryOnce(address, maxPages, maxTransactions, budget, signal)
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
async scanCombinedAddressHistoryOnce(address, maxPages, maxTransactions, budget, signal) {
|
|
806
|
+
if (budget.pageRequests >= maxPages) {
|
|
807
|
+
throw this.addressHistoryPaginationLimitError(address, maxPages);
|
|
808
|
+
}
|
|
809
|
+
const state = createAddressHistoryScanState();
|
|
810
|
+
let cursor;
|
|
811
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
812
|
+
while (true) {
|
|
813
|
+
budget.pageRequests += 1;
|
|
814
|
+
const suffix = cursor ? `?after_txid=${encodeURIComponent(cursor)}` : "";
|
|
815
|
+
const rows = reserveAddressHistoryTransactions(
|
|
816
|
+
budget,
|
|
817
|
+
await this.getJson(`/address/${encodeURIComponent(address)}/txs${suffix}`, {
|
|
818
|
+
signal
|
|
819
|
+
}),
|
|
820
|
+
maxTransactions,
|
|
821
|
+
address,
|
|
822
|
+
"mempool-qbit",
|
|
823
|
+
"mempool-qbit combined"
|
|
824
|
+
);
|
|
825
|
+
if (rows.length === 0) break;
|
|
826
|
+
if (budget.pageRequests === maxPages) {
|
|
827
|
+
throw this.addressHistoryPaginationLimitError(address, maxPages);
|
|
828
|
+
}
|
|
829
|
+
const txs = assertAddressHistoryPage(rows, address, "mempool-qbit combined");
|
|
830
|
+
cursor = nextAddressHistoryCursor(txs, cursor, seenCursors, address, "mempool-qbit");
|
|
831
|
+
appendAddressHistoryTransactions(state, txs, "combined", address, "mempool-qbit");
|
|
832
|
+
}
|
|
833
|
+
return { confirmed: state.confirmed, mempool: state.mempool };
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Confirmed transaction history for an address, newest first.
|
|
837
|
+
* mempool-qbit paginates combined history with `?after_txid=...` and sorts
|
|
838
|
+
* mempool entries first, so full mempool-only pages must still advance.
|
|
839
|
+
* Without `afterTxid`, an empty page right after a mempool row is checked
|
|
840
|
+
* against the address stats: qbit-electrs answers that cursor with `[]`
|
|
841
|
+
* instead of the confirmed rows that follow, so the call rejects with
|
|
842
|
+
* `invalid_cursor` if the stats count more confirmed transactions than it
|
|
843
|
+
* found.
|
|
844
|
+
*/
|
|
845
|
+
getAddressTxs(address, afterTxid, options) {
|
|
846
|
+
return this.runOperation(options, (signal) => this.scanAddressTxs(address, afterTxid, signal));
|
|
847
|
+
}
|
|
848
|
+
async scanAddressTxs(address, afterTxid, signal) {
|
|
849
|
+
const confirmedTxs = [];
|
|
850
|
+
let cursor;
|
|
851
|
+
let continuationFound = afterTxid === void 0;
|
|
852
|
+
let cursorIsMempoolTx = false;
|
|
853
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
854
|
+
const seenTxids = /* @__PURE__ */ new Set();
|
|
855
|
+
let pageCount = 0;
|
|
856
|
+
while (confirmedTxs.length < 25) {
|
|
857
|
+
if (pageCount >= this.maxAddressHistoryPages) {
|
|
858
|
+
throw this.addressHistoryPaginationLimitError(address);
|
|
859
|
+
}
|
|
860
|
+
pageCount += 1;
|
|
861
|
+
const suffix = cursor ? `?after_txid=${encodeURIComponent(cursor)}` : "";
|
|
862
|
+
const txs = await this.getJson(
|
|
863
|
+
`/address/${encodeURIComponent(address)}/txs${suffix}`,
|
|
864
|
+
{ signal }
|
|
865
|
+
);
|
|
866
|
+
if (txs.length === 0) {
|
|
867
|
+
if (!continuationFound) throw this.invalidAddressHistoryCursorError(address, afterTxid);
|
|
868
|
+
if (afterTxid === void 0 && cursorIsMempoolTx) {
|
|
869
|
+
await this.assertConfirmedHistoryReached(address, confirmedTxs.length, signal);
|
|
870
|
+
}
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
for (const tx of txs) {
|
|
874
|
+
if (!continuationFound) {
|
|
875
|
+
if (isConfirmedTx(tx)) seenTxids.add(tx.txid);
|
|
876
|
+
if (tx.txid !== afterTxid) continue;
|
|
877
|
+
if (!isConfirmedTx(tx)) {
|
|
878
|
+
throw this.invalidAddressHistoryCursorError(address, afterTxid);
|
|
879
|
+
}
|
|
880
|
+
continuationFound = true;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (isConfirmedTx(tx) && !seenTxids.has(tx.txid)) {
|
|
884
|
+
confirmedTxs.push(tx);
|
|
885
|
+
seenTxids.add(tx.txid);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const nextCursor = txs[txs.length - 1]?.txid;
|
|
889
|
+
if (confirmedTxs.length >= 25) break;
|
|
890
|
+
if (txs.length < MEMPOOL_QBIT_ADDRESS_PAGE_SIZE) {
|
|
891
|
+
if (!continuationFound) throw this.invalidAddressHistoryCursorError(address, afterTxid);
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
if (!nextCursor || nextCursor === cursor || seenCursors.has(nextCursor)) {
|
|
895
|
+
if (!continuationFound) throw this.invalidAddressHistoryCursorError(address, afterTxid);
|
|
896
|
+
throw this.nonAdvancingAddressHistoryCursorError(address, nextCursor ?? cursor);
|
|
897
|
+
}
|
|
898
|
+
seenCursors.add(nextCursor);
|
|
899
|
+
cursor = nextCursor;
|
|
900
|
+
cursorIsMempoolTx = !isConfirmedTx(txs[txs.length - 1]);
|
|
901
|
+
}
|
|
902
|
+
return confirmedTxs.slice(0, 25);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* qbit-electrs's combined route answers an `after_txid` that is the last
|
|
906
|
+
* mempool row with `[]`, not the confirmed rows that follow: it passes the
|
|
907
|
+
* mempool txid on as the chain cursor, which `history()` never finds
|
|
908
|
+
* (`src/rest.rs:992-998` in Qbit-Org/qbit-electrs@53102e9). Reject that
|
|
909
|
+
* empty page when the address stats count confirmed transactions the scan
|
|
910
|
+
* has not seen.
|
|
911
|
+
*/
|
|
912
|
+
async assertConfirmedHistoryReached(address, confirmedSeen, signal) {
|
|
913
|
+
const stats = addressHistoryStats(
|
|
914
|
+
await this.getJson(`/address/${encodeURIComponent(address)}`, { signal }, false),
|
|
915
|
+
address,
|
|
916
|
+
"mempool-qbit"
|
|
917
|
+
);
|
|
918
|
+
if (stats.chain_stats.tx_count > confirmedSeen) {
|
|
919
|
+
throw new QbitApiError(
|
|
920
|
+
"invalid_cursor",
|
|
921
|
+
`mempool-qbit combined history ended after a mempool transaction with ${confirmedSeen} of ${stats.chain_stats.tx_count} confirmed transactions: ${address}`
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* mempool-qbit does not expose `/txs/mempool`. Page through the combined
|
|
927
|
+
* history until confirmed entries begin, collecting the leading mempool set.
|
|
928
|
+
*/
|
|
929
|
+
getAddressMempoolTxs(address, options) {
|
|
930
|
+
return this.runOperation(options, (signal) => this.scanAddressMempoolTxs(address, signal));
|
|
931
|
+
}
|
|
932
|
+
async scanAddressMempoolTxs(address, signal) {
|
|
933
|
+
const mempoolTxs = [];
|
|
934
|
+
let cursor;
|
|
935
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
936
|
+
const seenTxids = /* @__PURE__ */ new Set();
|
|
937
|
+
let pageCount = 0;
|
|
938
|
+
while (true) {
|
|
939
|
+
if (pageCount >= this.maxAddressHistoryPages) {
|
|
940
|
+
throw this.addressHistoryPaginationLimitError(address);
|
|
941
|
+
}
|
|
942
|
+
pageCount += 1;
|
|
943
|
+
const suffix = cursor ? `?after_txid=${encodeURIComponent(cursor)}` : "";
|
|
944
|
+
const txs = await this.getJson(
|
|
945
|
+
`/address/${encodeURIComponent(address)}/txs${suffix}`,
|
|
946
|
+
{ signal }
|
|
947
|
+
);
|
|
948
|
+
if (txs.length === 0) break;
|
|
949
|
+
for (const tx of txs) {
|
|
950
|
+
if (!isConfirmedTx(tx) && !seenTxids.has(tx.txid)) {
|
|
951
|
+
mempoolTxs.push(tx);
|
|
952
|
+
seenTxids.add(tx.txid);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const nextCursor = txs[txs.length - 1]?.txid;
|
|
956
|
+
if (txs.some(isConfirmedTx) || txs.length < MEMPOOL_QBIT_ADDRESS_PAGE_SIZE) break;
|
|
957
|
+
if (!nextCursor || nextCursor === cursor || seenCursors.has(nextCursor)) {
|
|
958
|
+
throw this.nonAdvancingAddressHistoryCursorError(address, nextCursor ?? cursor);
|
|
959
|
+
}
|
|
960
|
+
seenCursors.add(nextCursor);
|
|
961
|
+
cursor = nextCursor;
|
|
962
|
+
}
|
|
963
|
+
return mempoolTxs;
|
|
964
|
+
}
|
|
965
|
+
getMempoolRecommendedFees(options) {
|
|
966
|
+
return this.getJson("/fees/recommended", options);
|
|
967
|
+
}
|
|
968
|
+
async getFeeEstimates(options) {
|
|
969
|
+
return feeEstimatesFromMempoolRecommendedFees(await this.getMempoolRecommendedFees(options));
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Broadcast via `/tx`, falling back to the `/tx/push` form route only when
|
|
973
|
+
* `/tx` reported that it did not submit the transaction. The fallback is a
|
|
974
|
+
* second submission, so it follows the same policy: an already-known reply
|
|
975
|
+
* resolves with the txid and an uncertain attempt rejects with
|
|
976
|
+
* `unknown_outcome`. An uncertain `/tx` attempt never falls back.
|
|
977
|
+
*
|
|
978
|
+
* One signal and deadline cover both requests, with the same abort rules as
|
|
979
|
+
* `EsploraClient.broadcastTx`. An abort or expired deadline during `/tx`
|
|
980
|
+
* never sends the `/tx/push` fallback.
|
|
981
|
+
*/
|
|
982
|
+
async broadcastTx(rawTxHex, options) {
|
|
983
|
+
const txid = resolveBroadcastTxid(rawTxHex, options);
|
|
984
|
+
return this.runOperation(options, async (signal) => {
|
|
985
|
+
try {
|
|
986
|
+
return await this.submitTx("/tx", rawTxHex, txid, { signal });
|
|
987
|
+
} catch (error) {
|
|
988
|
+
if (!shouldRetryBroadcastViaTxPush(error)) {
|
|
989
|
+
throw error;
|
|
990
|
+
}
|
|
991
|
+
return this.submitTx("/tx/push", `txHash=${rawTxHex}`, txid, { signal });
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
addressHistoryPaginationLimitError(address, maxPages = this.maxAddressHistoryPages) {
|
|
996
|
+
return new QbitApiError(
|
|
997
|
+
"pagination_limit",
|
|
998
|
+
`mempool-qbit address history exceeded ${maxPages} pages: ${address}`
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
invalidAddressHistoryCursorError(address, afterTxid) {
|
|
1002
|
+
return new QbitApiError(
|
|
1003
|
+
"invalid_cursor",
|
|
1004
|
+
`mempool-qbit address history cursor is stale or not confirmed: ${afterTxid} (${address})`
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
nonAdvancingAddressHistoryCursorError(address, cursor) {
|
|
1008
|
+
return new QbitApiError(
|
|
1009
|
+
"invalid_cursor",
|
|
1010
|
+
`mempool-qbit address history cursor stopped advancing: ${quotedResponseText(String(cursor ?? "(missing)"), this.baseUrl)} (${address})`
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
function addressBalanceFromStats(stats) {
|
|
1015
|
+
if (!isRecord(stats) || typeof stats.address !== "string" || !hasSafeTxoSums(stats.chain_stats) || !hasSafeTxoSums(stats.mempool_stats)) {
|
|
1016
|
+
throw new QbitApiError("parse", "address stats lacked a string address or non-negative safe-integer funded/spent sums");
|
|
1017
|
+
}
|
|
1018
|
+
const confirmedSats = stats.chain_stats.funded_txo_sum - stats.chain_stats.spent_txo_sum;
|
|
1019
|
+
const mempoolSats = stats.mempool_stats.funded_txo_sum - stats.mempool_stats.spent_txo_sum;
|
|
1020
|
+
const totalSats = confirmedSats + mempoolSats;
|
|
1021
|
+
if (confirmedSats < 0 || !isSafeSatAmount(totalSats)) {
|
|
1022
|
+
throw new QbitApiError("parse", "address stats implied a negative or unsafe balance");
|
|
1023
|
+
}
|
|
1024
|
+
return {
|
|
1025
|
+
address: stats.address,
|
|
1026
|
+
confirmedSats,
|
|
1027
|
+
mempoolSats,
|
|
1028
|
+
totalSats
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
var DEFAULT_MAX_FEE_RATE_SAT_VB = 1e4;
|
|
1032
|
+
var MAX_FEE_ESTIMATE_TARGET = 1008;
|
|
1033
|
+
function isPositiveFeeRate(value) {
|
|
1034
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
1035
|
+
}
|
|
1036
|
+
function isSafeSatAmount(value) {
|
|
1037
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
1038
|
+
}
|
|
1039
|
+
function hasSafeTxoSums(value) {
|
|
1040
|
+
return isRecord(value) && isSafeSatAmount(value["funded_txo_sum"]) && isSafeSatAmount(value["spent_txo_sum"]);
|
|
1041
|
+
}
|
|
1042
|
+
function feeEstimatesFromMempoolRecommendedFees(fees) {
|
|
1043
|
+
if (!isRecord(fees)) {
|
|
1044
|
+
throw new QbitApiError("parse", "mempool-qbit recommended fees were not a JSON object");
|
|
1045
|
+
}
|
|
1046
|
+
for (const field of ["fastestFee", "halfHourFee", "hourFee"]) {
|
|
1047
|
+
if (!isPositiveFeeRate(fees[field])) {
|
|
1048
|
+
throw new QbitApiError("parse", `mempool-qbit recommended fees had an invalid ${field}`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return {
|
|
1052
|
+
"1": fees.fastestFee,
|
|
1053
|
+
"2": fees.halfHourFee,
|
|
1054
|
+
"3": fees.hourFee
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
function feeTiersFromEstimates(estimates, options = {}) {
|
|
1058
|
+
const maxFeeRate = options.maxFeeRate ?? DEFAULT_MAX_FEE_RATE_SAT_VB;
|
|
1059
|
+
if (!isPositiveFeeRate(maxFeeRate)) {
|
|
1060
|
+
throw new TypeError("maxFeeRate must be a finite number above 0");
|
|
1061
|
+
}
|
|
1062
|
+
if (!isRecord(estimates)) {
|
|
1063
|
+
throw new QbitApiError("parse", "fee estimates were not a JSON object");
|
|
1064
|
+
}
|
|
1065
|
+
return Object.entries(estimates).map(([key, feeRate]) => {
|
|
1066
|
+
const target = Number(key);
|
|
1067
|
+
if (!/^[1-9]\d*$/.test(key) || target > MAX_FEE_ESTIMATE_TARGET) {
|
|
1068
|
+
throw new QbitApiError(
|
|
1069
|
+
"parse",
|
|
1070
|
+
`fee estimates had a confirmation target that is not a decimal block count from 1 to ${MAX_FEE_ESTIMATE_TARGET}`
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
if (!isPositiveFeeRate(feeRate) || feeRate > maxFeeRate) {
|
|
1074
|
+
throw new QbitApiError(
|
|
1075
|
+
"parse",
|
|
1076
|
+
`fee estimate for target ${target} was not a finite rate above 0 and at most ${maxFeeRate} sat/vB`
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
return { target, feeRate };
|
|
1080
|
+
}).sort((a, b) => a.target - b.target);
|
|
1081
|
+
}
|
|
1082
|
+
function txidFromRawTx(rawTxHex) {
|
|
1083
|
+
const txid = tryTxidFromRawTx(rawTxHex);
|
|
1084
|
+
if (txid === void 0) {
|
|
1085
|
+
throw new QbitApiError("parse", "raw transaction hex is not a well-formed transaction");
|
|
1086
|
+
}
|
|
1087
|
+
return txid;
|
|
1088
|
+
}
|
|
1089
|
+
function trimAsciiWhitespace(text) {
|
|
1090
|
+
let start = 0;
|
|
1091
|
+
let end = text.length;
|
|
1092
|
+
while (start < end && isAsciiWhitespace(text.charCodeAt(start))) start += 1;
|
|
1093
|
+
while (end > start && isAsciiWhitespace(text.charCodeAt(end - 1))) end -= 1;
|
|
1094
|
+
return text.slice(start, end);
|
|
1095
|
+
}
|
|
1096
|
+
function isAsciiWhitespace(code) {
|
|
1097
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
1098
|
+
}
|
|
1099
|
+
function tryTxidFromRawTx(rawTxHex) {
|
|
1100
|
+
const bytes = hexToBytes(rawTxHex);
|
|
1101
|
+
const stripped = bytes === void 0 ? void 0 : witnessStrippedTx(bytes);
|
|
1102
|
+
if (stripped === void 0) return void 0;
|
|
1103
|
+
return bytesToHex(sha256(sha256(stripped)).reverse());
|
|
1104
|
+
}
|
|
1105
|
+
function resolveBroadcastTxid(rawTxHex, options) {
|
|
1106
|
+
if (typeof rawTxHex !== "string") {
|
|
1107
|
+
throw new TypeError("broadcastTx: rawTxHex must be a hex string");
|
|
1108
|
+
}
|
|
1109
|
+
const requested = (options ?? {}).txid;
|
|
1110
|
+
const derived = tryTxidFromRawTx(rawTxHex);
|
|
1111
|
+
if (requested === void 0) return derived;
|
|
1112
|
+
const provided = typeof requested === "string" ? requested.toLowerCase() : "";
|
|
1113
|
+
if (!TXID_PATTERN.test(provided)) {
|
|
1114
|
+
throw new TypeError("broadcastTx: options.txid must be a 64-character hex transaction id");
|
|
1115
|
+
}
|
|
1116
|
+
if (derived !== void 0 && derived !== provided) {
|
|
1117
|
+
throw new TypeError(
|
|
1118
|
+
`broadcastTx: options.txid ${provided} does not match the transaction hex (${derived})`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
return provided;
|
|
1122
|
+
}
|
|
1123
|
+
var COMPACT_SIZE_MAX = 33554432;
|
|
1124
|
+
var RawTxReader = class {
|
|
1125
|
+
constructor(bytes) {
|
|
1126
|
+
this.bytes = bytes;
|
|
1127
|
+
this.offset = 0;
|
|
1128
|
+
}
|
|
1129
|
+
get position() {
|
|
1130
|
+
return this.offset;
|
|
1131
|
+
}
|
|
1132
|
+
get remaining() {
|
|
1133
|
+
return this.bytes.length - this.offset;
|
|
1134
|
+
}
|
|
1135
|
+
skip(length) {
|
|
1136
|
+
if (length > this.remaining) throw new RangeError("raw transaction is truncated");
|
|
1137
|
+
this.offset += length;
|
|
1138
|
+
}
|
|
1139
|
+
readUint8() {
|
|
1140
|
+
this.skip(1);
|
|
1141
|
+
return this.bytes[this.offset - 1];
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* qbit Core `ReadCompactSize` with its default range check (serialize.h:330
|
|
1145
|
+
* at v1.0.0). Only the shortest encoding of a value is valid: a `fd` prefix
|
|
1146
|
+
* must carry at least 0xfd, `fe` at least 0x10000 and `ff` at least
|
|
1147
|
+
* 0x100000000, or Core throws `non-canonical ReadCompactSize()`. A value
|
|
1148
|
+
* above MAX_SIZE (serialize.h:32) fails `size too large`, so every `ff`
|
|
1149
|
+
* encoding fails one check or the other. Core reads every count and length
|
|
1150
|
+
* in a transaction this way, witness stacks included.
|
|
1151
|
+
*/
|
|
1152
|
+
readCompactSize() {
|
|
1153
|
+
const first = this.readUint8();
|
|
1154
|
+
if (first < 253) return first;
|
|
1155
|
+
const width = first === 253 ? 2 : first === 254 ? 4 : 8;
|
|
1156
|
+
let value = 0;
|
|
1157
|
+
for (let i = 0; i < width; i += 1) value += this.readUint8() * 2 ** (8 * i);
|
|
1158
|
+
const minimum = first === 253 ? 253 : first === 254 ? 65536 : 2 ** 32;
|
|
1159
|
+
if (value < minimum) throw new RangeError("raw transaction has a non-canonical CompactSize");
|
|
1160
|
+
if (value > COMPACT_SIZE_MAX) throw new RangeError("raw transaction length is too large");
|
|
1161
|
+
return value;
|
|
1162
|
+
}
|
|
1163
|
+
skipVarBytes() {
|
|
1164
|
+
this.skip(this.readCompactSize());
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
function witnessStrippedTx(bytes) {
|
|
1168
|
+
const reader = new RawTxReader(bytes);
|
|
1169
|
+
try {
|
|
1170
|
+
reader.skip(4);
|
|
1171
|
+
const witness = reader.remaining >= 2 && bytes[4] === 0;
|
|
1172
|
+
if (witness) {
|
|
1173
|
+
if (bytes[5] !== 1) return void 0;
|
|
1174
|
+
reader.skip(2);
|
|
1175
|
+
}
|
|
1176
|
+
const bodyStart = reader.position;
|
|
1177
|
+
const inputCount = reader.readCompactSize();
|
|
1178
|
+
for (let i = 0; i < inputCount; i += 1) {
|
|
1179
|
+
reader.skip(36);
|
|
1180
|
+
reader.skipVarBytes();
|
|
1181
|
+
reader.skip(4);
|
|
1182
|
+
}
|
|
1183
|
+
const outputCount = reader.readCompactSize();
|
|
1184
|
+
for (let i = 0; i < outputCount; i += 1) {
|
|
1185
|
+
reader.skip(8);
|
|
1186
|
+
reader.skipVarBytes();
|
|
1187
|
+
}
|
|
1188
|
+
const bodyEnd = reader.position;
|
|
1189
|
+
if (witness) {
|
|
1190
|
+
let hasWitness = false;
|
|
1191
|
+
for (let i = 0; i < inputCount; i += 1) {
|
|
1192
|
+
const items = reader.readCompactSize();
|
|
1193
|
+
if (items > 0) hasWitness = true;
|
|
1194
|
+
for (let j = 0; j < items; j += 1) reader.skipVarBytes();
|
|
1195
|
+
}
|
|
1196
|
+
if (!hasWitness) return void 0;
|
|
1197
|
+
}
|
|
1198
|
+
reader.skip(4);
|
|
1199
|
+
if (reader.remaining !== 0) return void 0;
|
|
1200
|
+
if (!witness) return bytes;
|
|
1201
|
+
const stripped = new Uint8Array(4 + (bodyEnd - bodyStart) + 4);
|
|
1202
|
+
stripped.set(bytes.subarray(0, 4), 0);
|
|
1203
|
+
stripped.set(bytes.subarray(bodyStart, bodyEnd), 4);
|
|
1204
|
+
stripped.set(bytes.subarray(bytes.length - 4), stripped.length - 4);
|
|
1205
|
+
return stripped;
|
|
1206
|
+
} catch {
|
|
1207
|
+
return void 0;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
function hexToBytes(hex) {
|
|
1211
|
+
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) return void 0;
|
|
1212
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
1213
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
1214
|
+
bytes[i] = parseInt(hex.slice(2 * i, 2 * i + 2), 16);
|
|
1215
|
+
}
|
|
1216
|
+
return bytes;
|
|
1217
|
+
}
|
|
1218
|
+
function bytesToHex(bytes) {
|
|
1219
|
+
let hex = "";
|
|
1220
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
1221
|
+
return hex;
|
|
1222
|
+
}
|
|
1223
|
+
function createAddressHistoryScanState() {
|
|
1224
|
+
return {
|
|
1225
|
+
confirmed: [],
|
|
1226
|
+
mempool: [],
|
|
1227
|
+
seenTxids: /* @__PURE__ */ new Set()
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function createAddressHistoryBudget() {
|
|
1231
|
+
return { pageRequests: 0, rawTransactions: 0 };
|
|
1232
|
+
}
|
|
1233
|
+
var MAX_ADDRESS_HISTORY_RESCANS = 1;
|
|
1234
|
+
var ADDRESS_STATS_FIELDS = [
|
|
1235
|
+
"funded_txo_count",
|
|
1236
|
+
"funded_txo_sum",
|
|
1237
|
+
"spent_txo_count",
|
|
1238
|
+
"spent_txo_sum",
|
|
1239
|
+
"tx_count"
|
|
1240
|
+
];
|
|
1241
|
+
async function verifiedAddressHistory(address, backend, readStats, scan) {
|
|
1242
|
+
let before = addressHistoryStats(await readStats(), address, backend);
|
|
1243
|
+
for (let scans = 1; ; scans += 1) {
|
|
1244
|
+
const history = await scan();
|
|
1245
|
+
const after = addressHistoryStats(await readStats(), address, backend);
|
|
1246
|
+
if (!sameAddressHistoryStats(before, after)) {
|
|
1247
|
+
if (scans > MAX_ADDRESS_HISTORY_RESCANS) {
|
|
1248
|
+
throw new QbitApiError(
|
|
1249
|
+
"invalid_cursor",
|
|
1250
|
+
`${backend} address stats changed during each of ${scans} history scans: ${address}`
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
before = after;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const confirmed = after.chain_stats.tx_count;
|
|
1257
|
+
const mempool = after.mempool_stats.tx_count;
|
|
1258
|
+
if (history.confirmed.length !== confirmed || history.mempool.length !== mempool) {
|
|
1259
|
+
throw new QbitApiError(
|
|
1260
|
+
"invalid_cursor",
|
|
1261
|
+
`${backend} address history returned ${history.confirmed.length} confirmed and ${history.mempool.length} mempool transactions, but the address stats count ${confirmed} and ${mempool}: ${address}`
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
return history;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
function addressHistoryStats(value, address, backend) {
|
|
1268
|
+
if (!isRecord(value) || !hasTxCount(value["chain_stats"]) || !hasTxCount(value["mempool_stats"])) {
|
|
1269
|
+
throw new QbitApiError(
|
|
1270
|
+
"parse",
|
|
1271
|
+
`${backend} address stats lacked a non-negative safe-integer chain or mempool tx_count: ${address}`
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
return value;
|
|
1275
|
+
}
|
|
1276
|
+
function hasTxCount(value) {
|
|
1277
|
+
return isRecord(value) && Number.isSafeInteger(value["tx_count"]) && value["tx_count"] >= 0;
|
|
1278
|
+
}
|
|
1279
|
+
function sameAddressHistoryStats(a, b) {
|
|
1280
|
+
return ADDRESS_STATS_FIELDS.every(
|
|
1281
|
+
(field) => a.chain_stats[field] === b.chain_stats[field] && a.mempool_stats[field] === b.mempool_stats[field]
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
function positiveAddressHistoryLimit(value, defaultValue, name) {
|
|
1285
|
+
const limit = value ?? defaultValue;
|
|
1286
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
1287
|
+
throw new QbitApiError("http", `${name} must be a positive integer`);
|
|
1288
|
+
}
|
|
1289
|
+
return limit;
|
|
1290
|
+
}
|
|
1291
|
+
function assertAddressHistoryPage(value, address, source) {
|
|
1292
|
+
value.forEach((tx, index) => {
|
|
1293
|
+
const status = isRecord(tx) ? tx["status"] : void 0;
|
|
1294
|
+
const vin = isRecord(tx) ? tx["vin"] : void 0;
|
|
1295
|
+
const vout = isRecord(tx) ? tx["vout"] : void 0;
|
|
1296
|
+
if (!isRecord(tx) || typeof tx["txid"] !== "string" || !/^[0-9a-f]{64}$/i.test(tx["txid"]) || !isRecord(status) || typeof status["confirmed"] !== "boolean" || !Array.isArray(vin) || !vin.every(isRecord) || !Array.isArray(vout) || !vout.every(isRecord)) {
|
|
1297
|
+
throw new QbitApiError(
|
|
1298
|
+
"parse",
|
|
1299
|
+
`${source} address history transaction ${index} had an invalid txid, status, vin, or vout: ${address}`
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
const spendsFromAddress = vin.some(
|
|
1303
|
+
(input) => isRecord(input) && isRecord(input["prevout"]) && addressHistoryAddressMatches(input["prevout"]["scriptpubkey_address"], address)
|
|
1304
|
+
);
|
|
1305
|
+
const paysToAddress = vout.some(
|
|
1306
|
+
(output) => isRecord(output) && addressHistoryAddressMatches(output["scriptpubkey_address"], address)
|
|
1307
|
+
);
|
|
1308
|
+
if (!spendsFromAddress && !paysToAddress) {
|
|
1309
|
+
throw new QbitApiError(
|
|
1310
|
+
"parse",
|
|
1311
|
+
`${source} address history transaction ${index} did not touch ${address}`
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
return value;
|
|
1316
|
+
}
|
|
1317
|
+
function reserveAddressHistoryTransactions(budget, value, maxTransactions, address, backend, source) {
|
|
1318
|
+
if (!Array.isArray(value)) {
|
|
1319
|
+
throw new QbitApiError("parse", `${source} address history was not an array: ${address}`);
|
|
1320
|
+
}
|
|
1321
|
+
budget.rawTransactions += value.length;
|
|
1322
|
+
if (budget.rawTransactions > maxTransactions) {
|
|
1323
|
+
throw new QbitApiError(
|
|
1324
|
+
"pagination_limit",
|
|
1325
|
+
`${backend} address history exceeded ${maxTransactions} transactions: ${address}`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
return value;
|
|
1329
|
+
}
|
|
1330
|
+
function addressHistoryAddressMatches(value, address) {
|
|
1331
|
+
if (typeof value !== "string") return false;
|
|
1332
|
+
if (value === address) return true;
|
|
1333
|
+
if (!isUniformCaseQbitBech32(value) || !isUniformCaseQbitBech32(address)) return false;
|
|
1334
|
+
return value.toLowerCase() === address.toLowerCase();
|
|
1335
|
+
}
|
|
1336
|
+
var QBIT_BECH32_HRPS = /* @__PURE__ */ new Set(["qb", "tq", "qbrt"]);
|
|
1337
|
+
function isUniformCaseQbitBech32(value) {
|
|
1338
|
+
const normalized = value.toLowerCase();
|
|
1339
|
+
const separator = value.lastIndexOf("1");
|
|
1340
|
+
const hrp = normalized.slice(0, separator);
|
|
1341
|
+
const data = value.slice(separator + 1);
|
|
1342
|
+
const hasUniformCase = value === value.toLowerCase() || value === value.toUpperCase();
|
|
1343
|
+
return separator > 0 && QBIT_BECH32_HRPS.has(hrp) && data.length >= 6 && hasUniformCase && /^[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/i.test(data);
|
|
1344
|
+
}
|
|
1345
|
+
function nextAddressHistoryCursor(txs, cursor, seenCursors, address, backend) {
|
|
1346
|
+
const nextCursor = txs[txs.length - 1]?.txid;
|
|
1347
|
+
if (!nextCursor) {
|
|
1348
|
+
throw new QbitApiError(
|
|
1349
|
+
"invalid_cursor",
|
|
1350
|
+
`${backend} address history cursor stopped advancing: (missing) (${address})`
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
const normalizedNextCursor = nextCursor.toLowerCase();
|
|
1354
|
+
if (normalizedNextCursor === cursor?.toLowerCase() || seenCursors.has(normalizedNextCursor)) {
|
|
1355
|
+
throw new QbitApiError(
|
|
1356
|
+
"invalid_cursor",
|
|
1357
|
+
`${backend} address history cursor stopped advancing: ${nextCursor} (${address})`
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
seenCursors.add(normalizedNextCursor);
|
|
1361
|
+
return nextCursor;
|
|
1362
|
+
}
|
|
1363
|
+
function appendAddressHistoryTransactions(state, txs, source, address, backend) {
|
|
1364
|
+
for (const tx of txs) {
|
|
1365
|
+
const normalizedTxid = tx.txid.toLowerCase();
|
|
1366
|
+
if (state.seenTxids.has(normalizedTxid)) {
|
|
1367
|
+
throw new QbitApiError(
|
|
1368
|
+
"invalid_cursor",
|
|
1369
|
+
`${backend} address history repeated transaction ${tx.txid}: ${address}`
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
state.seenTxids.add(normalizedTxid);
|
|
1373
|
+
const confirmed = isConfirmedTx(tx);
|
|
1374
|
+
if (source === "confirmed" && !confirmed) {
|
|
1375
|
+
throw new QbitApiError(
|
|
1376
|
+
"parse",
|
|
1377
|
+
`esplora confirmed history returned an unconfirmed transaction ${tx.txid}: ${address}`
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
if (source === "mempool" && confirmed) {
|
|
1381
|
+
throw new QbitApiError(
|
|
1382
|
+
"parse",
|
|
1383
|
+
`esplora mempool history returned a confirmed transaction ${tx.txid}: ${address}`
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
if (confirmed) state.confirmed.push(tx);
|
|
1387
|
+
else state.mempool.push(tx);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
function addressHistoryPageLimitError(address, maxPages, backend) {
|
|
1391
|
+
return new QbitApiError(
|
|
1392
|
+
"pagination_limit",
|
|
1393
|
+
`${backend} address history exceeded ${maxPages} page fetches: ${address}`
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
function isConfirmedTx(tx) {
|
|
1397
|
+
return tx.status?.confirmed === true;
|
|
1398
|
+
}
|
|
1399
|
+
function unknownOutcomeError(url, txid, reason, options = {}) {
|
|
1400
|
+
const subject = txid === void 0 ? "the transaction" : `txid ${txid}`;
|
|
1401
|
+
return new QbitApiError(
|
|
1402
|
+
"unknown_outcome",
|
|
1403
|
+
`transaction submission outcome unknown (${reason}); ${subject} may have been accepted, check its status before resubmitting`,
|
|
1404
|
+
{ ...options, url, ...txid === void 0 ? {} : { txid } }
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
function isPreConnectionFailure(error) {
|
|
1408
|
+
const entries = collectErrorCodes(error, 0, []);
|
|
1409
|
+
return entries.length > 0 && entries.every(
|
|
1410
|
+
(entry) => PRE_CONNECTION_ERROR_CODES.has(entry.code) || CONNECT_STAGE_ERROR_CODES.has(entry.code) && entry.syscall === "connect"
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
function collectErrorCodes(error, depth, entries) {
|
|
1414
|
+
if (!isRecord(error)) return entries;
|
|
1415
|
+
if (depth > 5) {
|
|
1416
|
+
entries.push({ code: "UNJUDGED", syscall: void 0 });
|
|
1417
|
+
return entries;
|
|
1418
|
+
}
|
|
1419
|
+
const nested = error["errors"];
|
|
1420
|
+
if (Array.isArray(nested) && nested.length > 0) {
|
|
1421
|
+
for (const entry of nested) collectErrorCodes(entry, depth + 1, entries);
|
|
1422
|
+
} else if (typeof error["code"] === "string") {
|
|
1423
|
+
const syscall = error["syscall"];
|
|
1424
|
+
entries.push({ code: error["code"], syscall: typeof syscall === "string" ? syscall : void 0 });
|
|
1425
|
+
}
|
|
1426
|
+
collectErrorCodes(error["cause"], depth + 1, entries);
|
|
1427
|
+
return entries;
|
|
1428
|
+
}
|
|
1429
|
+
var ALREADY_KNOWN_BROADCAST_REPLY = /\b(?:txn already in mempool|txn already known|txn same nonwitness data in mempool|transaction already in block ?chain|transaction outputs already in utxo set)\b/;
|
|
1430
|
+
function isAlreadyKnownBroadcastReply(reply) {
|
|
1431
|
+
if (reply.code === -27) return true;
|
|
1432
|
+
return ALREADY_KNOWN_BROADCAST_REPLY.test(normalizeReplyText(reply.message));
|
|
1433
|
+
}
|
|
1434
|
+
function isRelayedTransportFailure(reply) {
|
|
1435
|
+
if (typeof reply.code === "string") return !PRE_CONNECTION_ERROR_CODES.has(reply.code);
|
|
1436
|
+
if (reply.code === -32603 || reply.code === -32602) return true;
|
|
1437
|
+
return /^connection error\b/.test(normalizeReplyText(reply.message));
|
|
1438
|
+
}
|
|
1439
|
+
var RPC_REQUEST_ERROR_CODES = /* @__PURE__ */ new Set([-32700, -32600, -32601]);
|
|
1440
|
+
function isNodeVerdictCode(code) {
|
|
1441
|
+
return code >= -99 && code <= -1 || RPC_REQUEST_ERROR_CODES.has(code);
|
|
1442
|
+
}
|
|
1443
|
+
var DEFINITE_REJECTION_REPLY = /^(?:bad txns |bad witness nonstandard|bad cb length|txn mempool conflict|tx decode failed|tx size\b|tx output not p2mr|scriptsig (?:size|not pushonly)|scriptpubkey\b|bare multisig|datacarrier|dust\b|non (?:bip68 )?final|(?:mempool |block |(?:non )?mandatory )script verify flag|min relay fee not met|mempool min fee not met|insufficient fee|max feerate exceeded|mempool full|too long mempool chain|truc violation|missing ephemeral spends|bip125 replacement disallowed|too many potential replacements|replacement adds unconfirmed|missing inputs|no mempool\b)/;
|
|
1444
|
+
function isDefiniteNodeRejection(reply) {
|
|
1445
|
+
if (typeof reply.code === "number") return isNodeVerdictCode(reply.code);
|
|
1446
|
+
if (typeof reply.code === "string") return PRE_CONNECTION_ERROR_CODES.has(reply.code);
|
|
1447
|
+
return DEFINITE_REJECTION_REPLY.test(normalizeReplyText(reply.message));
|
|
1448
|
+
}
|
|
1449
|
+
function shouldRetryBroadcastViaTxPush(error) {
|
|
1450
|
+
if (!QbitApiError.is(error)) return false;
|
|
1451
|
+
if (error.kind === "not_found" && error.status === 404 || error.kind === "http" && (error.status === 405 || error.status === 501)) {
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
const body = error instanceof QbitApiError ? rawResponseBodies.get(error) ?? error.responseBody : void 0;
|
|
1455
|
+
if (error.kind !== "http" || error.status !== 400 || body === void 0) {
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
1458
|
+
const reply = parseBroadcastReply(body);
|
|
1459
|
+
if (reply.code !== void 0) return false;
|
|
1460
|
+
return /^(?:(?:failed|unable) to|could not) (?:send|submit|broadcast)(?: raw)? transaction$/.test(
|
|
1461
|
+
normalizeReplyText(reply.message)
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
function parseBroadcastReply(body) {
|
|
1465
|
+
let text = body;
|
|
1466
|
+
try {
|
|
1467
|
+
const parsed = JSON.parse(body);
|
|
1468
|
+
if (typeof parsed === "string") {
|
|
1469
|
+
text = parsed;
|
|
1470
|
+
} else if (isRecord(parsed)) {
|
|
1471
|
+
const nested = parsed["error"];
|
|
1472
|
+
const code = errorCodeOf(nested) ?? errorCodeOf(parsed);
|
|
1473
|
+
let message = body;
|
|
1474
|
+
if (typeof nested === "string") message = nested;
|
|
1475
|
+
else if (isRecord(nested) && typeof nested["message"] === "string") message = nested["message"];
|
|
1476
|
+
else if (typeof parsed["message"] === "string") message = parsed["message"];
|
|
1477
|
+
return { code: code ?? embeddedErrorCode(message), message };
|
|
1478
|
+
}
|
|
1479
|
+
} catch {
|
|
1480
|
+
}
|
|
1481
|
+
return { code: embeddedErrorCode(text), message: text };
|
|
1482
|
+
}
|
|
1483
|
+
function errorCodeOf(value) {
|
|
1484
|
+
if (!isRecord(value)) return void 0;
|
|
1485
|
+
const code = value["code"];
|
|
1486
|
+
if (typeof code === "number") return Number.isInteger(code) && code < 0 ? code : void 0;
|
|
1487
|
+
if (typeof code !== "string") return void 0;
|
|
1488
|
+
const transportCode = code.toUpperCase();
|
|
1489
|
+
return TRANSPORT_ERROR_CODE_PATTERN.test(transportCode) ? transportCode : void 0;
|
|
1490
|
+
}
|
|
1491
|
+
function embeddedErrorCode(text) {
|
|
1492
|
+
const start = text.indexOf("{");
|
|
1493
|
+
const end = text.lastIndexOf("}");
|
|
1494
|
+
if (start === -1 || end <= start) return void 0;
|
|
1495
|
+
try {
|
|
1496
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
1497
|
+
if (isRecord(parsed)) return errorCodeOf(parsed["error"]) ?? errorCodeOf(parsed);
|
|
1498
|
+
} catch {
|
|
1499
|
+
}
|
|
1500
|
+
return void 0;
|
|
1501
|
+
}
|
|
1502
|
+
function normalizeReplyText(text) {
|
|
1503
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
1504
|
+
}
|
|
1505
|
+
function isRecord(value) {
|
|
1506
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1507
|
+
}
|
|
1508
|
+
function parseRetryAfter(value) {
|
|
1509
|
+
if (!value) return void 0;
|
|
1510
|
+
let ms;
|
|
1511
|
+
const seconds = Number(value);
|
|
1512
|
+
if (!Number.isNaN(seconds)) {
|
|
1513
|
+
ms = seconds * 1e3;
|
|
1514
|
+
} else {
|
|
1515
|
+
const date = Date.parse(value);
|
|
1516
|
+
if (!Number.isFinite(date)) return void 0;
|
|
1517
|
+
ms = date - Date.now();
|
|
1518
|
+
}
|
|
1519
|
+
return Math.min(Math.max(0, Math.ceil(ms)), Number.MAX_SAFE_INTEGER);
|
|
1520
|
+
}
|
|
1521
|
+
function isAbortError(error) {
|
|
1522
|
+
return typeof error === "object" && error !== null && error.name === "AbortError";
|
|
1523
|
+
}
|
|
1524
|
+
function isLoopbackHostname(hostname) {
|
|
1525
|
+
return hostname === "localhost" || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
|
|
1526
|
+
}
|
|
1527
|
+
var DEFAULT_PORTS = {
|
|
1528
|
+
"http:": "80",
|
|
1529
|
+
"https:": "443",
|
|
1530
|
+
"ws:": "80",
|
|
1531
|
+
"wss:": "443",
|
|
1532
|
+
"ftp:": "21"
|
|
1533
|
+
};
|
|
1534
|
+
function schemeAndHost(url, base) {
|
|
1535
|
+
const match = /^([a-z][a-z\d+.-]*:)[/\\]*([^/?#\\]*)([^#]*)/i.exec(standardHref(url, base) ?? url);
|
|
1536
|
+
if (!match) return void 0;
|
|
1537
|
+
const protocol = match[1].toLowerCase();
|
|
1538
|
+
const at = match[2].lastIndexOf("@");
|
|
1539
|
+
const authority = match[2].slice(at + 1);
|
|
1540
|
+
const [, hostname, port] = /^(\[[^\]]*\]|[^:]*):?(.*)$/s.exec(authority);
|
|
1541
|
+
return {
|
|
1542
|
+
protocol,
|
|
1543
|
+
hostname: hostname.toLowerCase().replace(/\.$/, ""),
|
|
1544
|
+
userinfo: at !== -1,
|
|
1545
|
+
port: port === DEFAULT_PORTS[protocol] ? "" : port,
|
|
1546
|
+
path: match[3].startsWith("/") ? match[3] : `/${match[3]}`
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
function standardHref(url, base) {
|
|
1550
|
+
if (!followsUrlStandard()) return void 0;
|
|
1551
|
+
try {
|
|
1552
|
+
return new URL(url, base).href;
|
|
1553
|
+
} catch {
|
|
1554
|
+
return void 0;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
function followsUrlStandard() {
|
|
1558
|
+
try {
|
|
1559
|
+
return new URL("HTTP://Q.EXAMPLE:80/a/../b").href === "http://q.example/b";
|
|
1560
|
+
} catch {
|
|
1561
|
+
return false;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
function fromAnotherUrl(response, url) {
|
|
1565
|
+
if (typeof response.url !== "string" || response.url === "") return false;
|
|
1566
|
+
const requested = schemeAndHost(url);
|
|
1567
|
+
const replied = schemeAndHost(response.url);
|
|
1568
|
+
if (!requested || !replied) return true;
|
|
1569
|
+
return replied.protocol !== requested.protocol || replied.hostname !== requested.hostname || replied.port !== requested.port || replied.path !== requested.path;
|
|
1570
|
+
}
|
|
1571
|
+
function refuseRedirect(response, url, baseUrl) {
|
|
1572
|
+
const opaque = response.type === "opaqueredirect";
|
|
1573
|
+
const redirectStatus = response.status >= 300 && response.status <= 399;
|
|
1574
|
+
if (!opaque && !redirectStatus && response.redirected !== true && !fromAnotherUrl(response, url)) {
|
|
1575
|
+
return response;
|
|
1576
|
+
}
|
|
1577
|
+
void discardResponseBody(response);
|
|
1578
|
+
const errorUrl = redactBaseUrl(url, baseUrl);
|
|
1579
|
+
if (opaque) {
|
|
1580
|
+
throw new QbitApiError("http", `redirect refused (target hidden by the browser): ${errorUrl}`, { url: errorUrl });
|
|
1581
|
+
}
|
|
1582
|
+
if (redirectStatus) {
|
|
1583
|
+
const target2 = redactRedirectTarget(redirectTargetOrigin(response.headers.get("location"), url), baseUrl);
|
|
1584
|
+
throw new QbitApiError("http", `HTTP ${response.status} redirect to ${target2} refused: ${errorUrl}`, {
|
|
1585
|
+
status: response.status,
|
|
1586
|
+
url: errorUrl
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
const target = redactRedirectTarget(redirectTargetOrigin(response.url, url), baseUrl);
|
|
1590
|
+
throw new QbitApiError("http", `fetch followed a redirect to ${target}; reply discarded: ${errorUrl}`, {
|
|
1591
|
+
url: errorUrl
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
function redirectTargetOrigin(location, url) {
|
|
1595
|
+
if (!location) return "an unspecified location";
|
|
1596
|
+
const target = schemeAndHost(location, url) ?? schemeAndHost(/^[/\\]{2}/.test(location) ? `${schemeAndHost(url)?.protocol ?? ""}${location}` : url);
|
|
1597
|
+
if (!target) return "an invalid location";
|
|
1598
|
+
if (!/^(?:https?|wss?|ftp):$/.test(target.protocol)) return `a ${target.protocol} URL`;
|
|
1599
|
+
if (!/^(?:[a-z\d._~-]+|\[[a-f\d:.]+\])$/.test(target.hostname) || !/^\d*$/.test(target.port)) {
|
|
1600
|
+
return "an invalid location";
|
|
1601
|
+
}
|
|
1602
|
+
return `${target.protocol}//${target.hostname}${target.port ? `:${target.port}` : ""}`;
|
|
1603
|
+
}
|
|
1604
|
+
function redactedBaseUrl(baseUrl) {
|
|
1605
|
+
let href = baseUrl;
|
|
1606
|
+
try {
|
|
1607
|
+
href = new URL(baseUrl).href;
|
|
1608
|
+
} catch {
|
|
1609
|
+
}
|
|
1610
|
+
const match = /^(https?:)[/\\]*([^/?#\\]*)/i.exec(href);
|
|
1611
|
+
if (!match) return "...";
|
|
1612
|
+
const host = match[2].slice(match[2].lastIndexOf("@") + 1).toLowerCase();
|
|
1613
|
+
if (!/^(?:[a-z\d._~-]+|\[[a-f\d:.]+\])(?::\d*)?$/.test(host) || href.slice(match[0].length).includes("@")) {
|
|
1614
|
+
return "...";
|
|
1615
|
+
}
|
|
1616
|
+
const origin = `${match[1].toLowerCase()}//${host}`;
|
|
1617
|
+
return basePath(href) ? `${origin}/...` : origin;
|
|
1618
|
+
}
|
|
1619
|
+
function redactBaseUrl(text, baseUrl) {
|
|
1620
|
+
const spellings = [baseUrl];
|
|
1621
|
+
try {
|
|
1622
|
+
spellings.push(new URL(baseUrl).href.replace(/\/+$/, ""));
|
|
1623
|
+
} catch {
|
|
1624
|
+
}
|
|
1625
|
+
const redacted = redactedBaseUrl(baseUrl);
|
|
1626
|
+
const replacements = /* @__PURE__ */ new Map();
|
|
1627
|
+
for (const part of spellings.flatMap(baseUrlParts)) replacements.set(part, "...");
|
|
1628
|
+
for (const path of spellings.map(basePath)) if (path) replacements.set(path, "/...");
|
|
1629
|
+
for (const spelling of spellings) if (spelling) replacements.set(spelling, redacted);
|
|
1630
|
+
if (replacements.size === 0) return text;
|
|
1631
|
+
const pattern = [...replacements.keys()].sort((a, b) => b.length - a.length).map((needle) => needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
1632
|
+
return text.replace(new RegExp(pattern, "g"), (match) => replacements.get(match));
|
|
1633
|
+
}
|
|
1634
|
+
function baseUrlParts(url) {
|
|
1635
|
+
const match = /^(?:[a-z][a-z\d+.-]*:[/\\]*)?([^?#]*)(?:\?([^#]*))?/i.exec(url);
|
|
1636
|
+
const beforeQuery = match[1];
|
|
1637
|
+
const at = beforeQuery.lastIndexOf("@");
|
|
1638
|
+
const userinfo = at === -1 ? "" : beforeQuery.slice(0, at);
|
|
1639
|
+
const colon = userinfo.indexOf(":");
|
|
1640
|
+
const parts = [
|
|
1641
|
+
...colon === -1 ? [userinfo] : [userinfo.slice(0, colon), userinfo.slice(colon + 1)],
|
|
1642
|
+
...beforeQuery.split(/[/\\]/).slice(1),
|
|
1643
|
+
...beforeQuery.slice(at + 1).split(/[/\\]/).slice(1),
|
|
1644
|
+
...(match[2] ?? "").split("&").map((pair) => pair.slice(pair.indexOf("=") + 1))
|
|
1645
|
+
];
|
|
1646
|
+
return parts.flatMap((part) => [part, percentDecoded(part)]).filter((part) => part.length >= 8);
|
|
1647
|
+
}
|
|
1648
|
+
function percentDecoded(text) {
|
|
1649
|
+
try {
|
|
1650
|
+
return decodeURIComponent(text);
|
|
1651
|
+
} catch {
|
|
1652
|
+
return text;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
function basePath(url) {
|
|
1656
|
+
const path = /^[a-z][a-z\d+.-]*:[/\\]*[^/?#\\]*(.*)$/i.exec(url)?.[1] ?? "";
|
|
1657
|
+
return path === "/" ? "" : path;
|
|
1658
|
+
}
|
|
1659
|
+
function redactRedirectTarget(target, baseUrl) {
|
|
1660
|
+
const redacted = redactBaseUrl(target, baseUrl);
|
|
1661
|
+
const parts = [...new Set(baseUrlParts(baseUrl))].sort((a, b) => b.length - a.length);
|
|
1662
|
+
if (parts.length === 0) return redacted;
|
|
1663
|
+
const pattern = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
1664
|
+
return redacted.replace(new RegExp(pattern, "gi"), "...");
|
|
1665
|
+
}
|
|
1666
|
+
function responseBodyPreview(text, baseUrl) {
|
|
1667
|
+
return redactBaseUrl(text.trim(), baseUrl).slice(0, 200);
|
|
1668
|
+
}
|
|
1669
|
+
function quotedResponseText(text, baseUrl) {
|
|
1670
|
+
return escapeControlChars(responseBodyPreview(text, baseUrl));
|
|
1671
|
+
}
|
|
1672
|
+
var REDACTED_CAUSE_DEPTH = 5;
|
|
1673
|
+
var NATIVE_ERRORS = [
|
|
1674
|
+
Error,
|
|
1675
|
+
TypeError,
|
|
1676
|
+
RangeError,
|
|
1677
|
+
SyntaxError,
|
|
1678
|
+
ReferenceError,
|
|
1679
|
+
EvalError,
|
|
1680
|
+
URIError
|
|
1681
|
+
];
|
|
1682
|
+
function causeOptions(cause, baseUrl) {
|
|
1683
|
+
return { cause: redactedCause(cause, baseUrl, REDACTED_CAUSE_DEPTH), unredactedCause: cause };
|
|
1684
|
+
}
|
|
1685
|
+
function redactedCause(value, baseUrl, depth) {
|
|
1686
|
+
if (typeof value === "string") return redactBaseUrl(value, baseUrl);
|
|
1687
|
+
if (typeof value === "function") return void 0;
|
|
1688
|
+
if (typeof value !== "object" || value === null) return value;
|
|
1689
|
+
if (depth === 0) return void 0;
|
|
1690
|
+
if (Array.isArray(value)) return value.map((item) => redactedCause(item, baseUrl, depth - 1));
|
|
1691
|
+
try {
|
|
1692
|
+
let copy;
|
|
1693
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1694
|
+
if (value instanceof Error) {
|
|
1695
|
+
const ErrorClass = NATIVE_ERRORS.find((errorClass) => errorClass === value.constructor) ?? Error;
|
|
1696
|
+
const error = new ErrorClass(redactBaseUrl(String(value.message), baseUrl));
|
|
1697
|
+
if (error.name !== value.name) {
|
|
1698
|
+
Object.defineProperty(error, "name", { value: String(value.name), writable: true, configurable: true });
|
|
1699
|
+
}
|
|
1700
|
+
error.stack = typeof value.stack === "string" ? redactBaseUrl(value.stack, baseUrl) : `${error.name}: ${error.message}`;
|
|
1701
|
+
copy = error;
|
|
1702
|
+
} else if (prototype === Object.prototype || prototype === null) {
|
|
1703
|
+
copy = {};
|
|
1704
|
+
} else {
|
|
1705
|
+
return void 0;
|
|
1706
|
+
}
|
|
1707
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
1708
|
+
if (value instanceof Error && (key === "message" || key === "stack")) continue;
|
|
1709
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1710
|
+
if (descriptor === void 0 || !("value" in descriptor)) continue;
|
|
1711
|
+
const redacted = redactedCause(descriptor.value, baseUrl, depth - 1);
|
|
1712
|
+
if (redacted === void 0 && descriptor.value !== void 0) continue;
|
|
1713
|
+
Object.defineProperty(copy, key, {
|
|
1714
|
+
value: redacted,
|
|
1715
|
+
enumerable: descriptor.enumerable ?? false,
|
|
1716
|
+
writable: true,
|
|
1717
|
+
configurable: true
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
return copy;
|
|
1721
|
+
} catch {
|
|
1722
|
+
return void 0;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
function escapeControlChars(text) {
|
|
1726
|
+
return text.replace(/[\p{Cc}\p{Zl}\p{Zp}\p{Bidi_Control}]/gu, (char) => {
|
|
1727
|
+
if (char === "\n") return "\\n";
|
|
1728
|
+
if (char === "\r") return "\\r";
|
|
1729
|
+
if (char === " ") return "\\t";
|
|
1730
|
+
return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
function formatErrorCause(error) {
|
|
1734
|
+
if (error instanceof Error) {
|
|
1735
|
+
return ` (${error.name}: ${error.message})`;
|
|
1736
|
+
}
|
|
1737
|
+
if (typeof error === "string") {
|
|
1738
|
+
return ` (${error})`;
|
|
1739
|
+
}
|
|
1740
|
+
return "";
|
|
1741
|
+
}
|
|
1742
|
+
async function readBody(response, url, baseUrl, signal) {
|
|
1743
|
+
if (signal.aborted) {
|
|
1744
|
+
const error = abortErrorFor(signal);
|
|
1745
|
+
void discardResponseBody(response, error);
|
|
1746
|
+
throw error;
|
|
1747
|
+
}
|
|
1748
|
+
try {
|
|
1749
|
+
return await readBodyUntilAborted(response, signal);
|
|
1750
|
+
} catch (cause) {
|
|
1751
|
+
if (signal.aborted) throw abortErrorFor(signal);
|
|
1752
|
+
throw bodyReadError(response, cause, url, baseUrl);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
function bodyReadError(response, cause, url, baseUrl) {
|
|
1756
|
+
return new QbitApiError("parse", `failed to read response body from ${url}`, {
|
|
1757
|
+
url,
|
|
1758
|
+
status: response.status,
|
|
1759
|
+
...causeOptions(cause, baseUrl)
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
async function readBodyUntilAborted(response, signal) {
|
|
1763
|
+
let rejectAborted = () => {
|
|
1764
|
+
};
|
|
1765
|
+
const aborted = new Promise((_, reject) => {
|
|
1766
|
+
rejectAborted = reject;
|
|
1767
|
+
});
|
|
1768
|
+
aborted.catch(() => {
|
|
1769
|
+
});
|
|
1770
|
+
const onAbort = () => rejectAborted(signal.reason);
|
|
1771
|
+
if (signal.aborted) onAbort();
|
|
1772
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
1773
|
+
try {
|
|
1774
|
+
const body = response.body;
|
|
1775
|
+
if (!body || typeof body.getReader !== "function" || typeof TextDecoder !== "function") {
|
|
1776
|
+
try {
|
|
1777
|
+
return await Promise.race([response.text(), aborted]);
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
if (signal.aborted) releaseBody(body, signal.reason);
|
|
1780
|
+
throw error;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
const reader = body.getReader();
|
|
1784
|
+
const decoder = new TextDecoder();
|
|
1785
|
+
let text = "";
|
|
1786
|
+
try {
|
|
1787
|
+
while (true) {
|
|
1788
|
+
const chunk = await Promise.race([reader.read(), aborted]);
|
|
1789
|
+
if (chunk.done) return text + decoder.decode();
|
|
1790
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
1791
|
+
}
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
reader.cancel(error).catch(() => {
|
|
1794
|
+
});
|
|
1795
|
+
throw error;
|
|
1796
|
+
}
|
|
1797
|
+
} finally {
|
|
1798
|
+
signal.removeEventListener("abort", onAbort);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
function releaseBody(body, reason) {
|
|
1802
|
+
if (body === null || typeof body !== "object") return;
|
|
1803
|
+
const stream = body;
|
|
1804
|
+
try {
|
|
1805
|
+
if (typeof stream.cancel === "function") {
|
|
1806
|
+
const result = stream.cancel(reason);
|
|
1807
|
+
if (result && typeof result.then === "function") result.then(void 0, () => {
|
|
1808
|
+
});
|
|
1809
|
+
} else if (typeof stream.destroy === "function") {
|
|
1810
|
+
const handled = typeof stream.listenerCount === "function" && Number(stream.listenerCount("error")) > 0;
|
|
1811
|
+
if (handled) stream.destroy(reason);
|
|
1812
|
+
else stream.destroy();
|
|
1813
|
+
}
|
|
1814
|
+
} catch {
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([204, 205, 304]);
|
|
1818
|
+
function bufferedResponse(response, text) {
|
|
1819
|
+
const body = NULL_BODY_STATUSES.has(response.status) ? null : text;
|
|
1820
|
+
const buffered = constructedResponse(response, body) ?? plainBufferedResponse(response, body ?? "");
|
|
1821
|
+
Object.defineProperties(buffered, {
|
|
1822
|
+
url: { value: response.url },
|
|
1823
|
+
headers: { value: response.headers },
|
|
1824
|
+
clone: { value: () => bufferedResponse(response, text) }
|
|
1825
|
+
});
|
|
1826
|
+
return buffered;
|
|
1827
|
+
}
|
|
1828
|
+
function constructedResponse(response, body) {
|
|
1829
|
+
if (typeof Response !== "function") return void 0;
|
|
1830
|
+
try {
|
|
1831
|
+
return new Response(body, { status: response.status, statusText: response.statusText });
|
|
1832
|
+
} catch {
|
|
1833
|
+
return void 0;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
function plainBufferedResponse(response, text) {
|
|
1837
|
+
let bodyUsed = false;
|
|
1838
|
+
const consume = async () => {
|
|
1839
|
+
if (bodyUsed) throw new TypeError("Body has already been consumed");
|
|
1840
|
+
bodyUsed = true;
|
|
1841
|
+
return text;
|
|
1842
|
+
};
|
|
1843
|
+
return {
|
|
1844
|
+
status: response.status,
|
|
1845
|
+
statusText: response.statusText,
|
|
1846
|
+
ok: response.status >= 200 && response.status <= 299,
|
|
1847
|
+
redirected: response.redirected ?? false,
|
|
1848
|
+
type: response.type ?? "default",
|
|
1849
|
+
body: null,
|
|
1850
|
+
get bodyUsed() {
|
|
1851
|
+
return bodyUsed;
|
|
1852
|
+
},
|
|
1853
|
+
text: consume,
|
|
1854
|
+
json: async () => JSON.parse(await consume())
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
async function discardResponseBody(response, reason) {
|
|
1858
|
+
if (response.body === null || response.bodyUsed) return;
|
|
1859
|
+
try {
|
|
1860
|
+
await response.body.cancel(reason);
|
|
1861
|
+
} catch {
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
function httpReplyMessage(status, url, detail) {
|
|
1865
|
+
return `HTTP ${status} from ${url}${detail ? `: ${escapeControlChars(detail)}` : ""}`;
|
|
1866
|
+
}
|
|
1867
|
+
var rawResponseBodies = /* @__PURE__ */ new WeakMap();
|
|
1868
|
+
function httpError(response, body, url, baseUrl) {
|
|
1869
|
+
const status = response.status;
|
|
1870
|
+
const detail = responseBodyPreview(body, baseUrl);
|
|
1871
|
+
const message = httpReplyMessage(status, url, detail);
|
|
1872
|
+
const options = { status, url, responseBody: detail };
|
|
1873
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
1874
|
+
if (retryAfterMs !== void 0) options.retryAfterMs = retryAfterMs;
|
|
1875
|
+
const error = status === 429 ? new QbitApiError("rate_limit", message, options) : status === 413 ? new QbitApiError("payload_too_large", message, options) : status === 404 ? new QbitApiError("not_found", message, options) : new QbitApiError("http", message, options);
|
|
1876
|
+
rawResponseBodies.set(error, body.trim().slice(0, 200));
|
|
1877
|
+
return error;
|
|
1878
|
+
}
|
|
1879
|
+
export {
|
|
1880
|
+
DEFAULT_MAX_FEE_RATE_SAT_VB,
|
|
1881
|
+
EsploraClient,
|
|
1882
|
+
MempoolQbitClient,
|
|
1883
|
+
QbitApiError,
|
|
1884
|
+
addressBalanceFromStats,
|
|
1885
|
+
feeEstimatesFromMempoolRecommendedFees,
|
|
1886
|
+
feeTiersFromEstimates,
|
|
1887
|
+
txidFromRawTx
|
|
1888
|
+
};
|
|
1889
|
+
//# sourceMappingURL=index.mjs.map
|