@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/src/index.ts ADDED
@@ -0,0 +1,3069 @@
1
+ // @qbit-oss/api — typed esplora (electrs) REST client for qbit.
2
+ //
3
+ // Speaks the standard esplora REST surface directly (no facade), against a
4
+ // configurable per-chain base URL. Deliberately carries NONE of the legacy
5
+ // centralized surface: no asset/token endpoints, no remote wallet config, and
6
+ // no device-id / channel / client telemetry headers. The test suite enforces
7
+ // this by construction (see the guard in test/).
8
+
9
+ import { sha256 } from '@noble/hashes/sha256'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Esplora response types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface EsploraChainStats {
16
+ funded_txo_count: number
17
+ funded_txo_sum: number
18
+ spent_txo_count: number
19
+ spent_txo_sum: number
20
+ tx_count: number
21
+ }
22
+
23
+ export interface EsploraAddressStats {
24
+ address: string
25
+ chain_stats: EsploraChainStats
26
+ mempool_stats: EsploraChainStats
27
+ }
28
+
29
+ export interface EsploraTxStatus {
30
+ confirmed: boolean
31
+ block_height?: number
32
+ block_hash?: string
33
+ block_time?: number
34
+ }
35
+
36
+ export interface EsploraUtxo {
37
+ txid: string
38
+ vout: number
39
+ value: number
40
+ status: EsploraTxStatus
41
+ }
42
+
43
+ export interface EsploraVin {
44
+ txid: string
45
+ vout: number
46
+ prevout: EsploraVout | null
47
+ scriptsig: string
48
+ scriptsig_asm: string
49
+ witness?: string[]
50
+ is_coinbase: boolean
51
+ sequence: number
52
+ }
53
+
54
+ export interface EsploraVout {
55
+ scriptpubkey: string
56
+ scriptpubkey_asm: string
57
+ scriptpubkey_type: string
58
+ scriptpubkey_address?: string
59
+ value: number
60
+ }
61
+
62
+ export interface EsploraTx {
63
+ txid: string
64
+ version: number
65
+ locktime: number
66
+ vin: EsploraVin[]
67
+ vout: EsploraVout[]
68
+ size: number
69
+ weight: number
70
+ fee: number
71
+ status: EsploraTxStatus
72
+ }
73
+
74
+ /** esplora returns `{ "<target_blocks>": <sat/vB> }`, e.g. `{ "1": 12.3, "6": 4.1 }`. */
75
+ export type EsploraFeeEstimates = Record<string, number>
76
+
77
+ /**
78
+ * mempool-qbit retains mempool's field names, but the first three values come
79
+ * from projected blocks 1, 2, and 3. Economy and minimum are policy floors,
80
+ * not confirmation-target estimates.
81
+ */
82
+ export interface MempoolRecommendedFees {
83
+ fastestFee: number
84
+ halfHourFee: number
85
+ hourFee: number
86
+ economyFee: number
87
+ minimumFee: number
88
+ }
89
+
90
+ export interface AddressHistory {
91
+ confirmed: EsploraTx[]
92
+ mempool: EsploraTx[]
93
+ }
94
+
95
+ /**
96
+ * Per-call cancellation and time budget. Both cover every dependent request of
97
+ * the call — pagination, retries, backoff, and response-body reads.
98
+ */
99
+ export interface RequestOptions {
100
+ /**
101
+ * Aborting rejects the call with an `aborted` `QbitApiError` and stops further
102
+ * requests (`unknown_outcome` from `broadcastTx` once its POST may have been sent).
103
+ */
104
+ signal?: AbortSignal
105
+ /**
106
+ * Overall budget for the call in ms; exceeding it rejects with a `timeout`
107
+ * `QbitApiError` (`unknown_outcome` from `broadcastTx` once its POST may have been sent).
108
+ */
109
+ deadlineMs?: number
110
+ }
111
+
112
+ export interface AddressHistoryOptions extends RequestOptions {
113
+ /**
114
+ * Maximum history page fetches for the whole call, including the terminal
115
+ * empty response and a rescan (default 256). The address-stats reads are not
116
+ * page fetches, nor is `EsploraClient`'s `/txs/mempool` fetch.
117
+ */
118
+ maxPages?: number
119
+ /**
120
+ * Maximum raw transaction rows accepted for the whole call, a rescan
121
+ * included, before failing closed (default 6,400).
122
+ */
123
+ maxTransactions?: number
124
+ }
125
+
126
+ export interface BroadcastTxOptions extends RequestOptions {
127
+ /**
128
+ * Transaction id (64 hex characters) of `rawTxHex`. Derived from the hex when
129
+ * omitted; when both are available they must agree.
130
+ */
131
+ txid?: string
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Error taxonomy (user-presentable)
136
+ // ---------------------------------------------------------------------------
137
+
138
+ export type QbitApiErrorKind =
139
+ | 'rate_limit' // HTTP 429
140
+ | 'payload_too_large' // HTTP 413
141
+ | 'not_found' // HTTP 404
142
+ | 'http' // other non-2xx
143
+ | 'network' // fetch threw (offline / DNS / CORS)
144
+ | 'timeout' // request or call deadline expired
145
+ | 'aborted' // caller's AbortSignal fired
146
+ | 'parse' // body was not the expected JSON/text
147
+ | 'pagination_limit' // history scan exceeded its configured page or transaction bound
148
+ | 'invalid_cursor' // an address-history scan could not be shown complete; retry it
149
+ | 'unknown_outcome' // a submission may have reached the node; check the txid before resubmitting
150
+
151
+ export interface QbitApiErrorOptions {
152
+ status?: number
153
+ url?: string
154
+ cause?: unknown
155
+ /**
156
+ * The underlying error before redaction. It is kept off the error object:
157
+ * read it with `QbitApiError.unredactedCause(error)`.
158
+ */
159
+ unredactedCause?: unknown
160
+ retryAfterMs?: number
161
+ /**
162
+ * Trimmed response body preview, capped at 200 characters. Errors the client
163
+ * creates redact the base URL from it as they do in `message`.
164
+ */
165
+ responseBody?: string
166
+ /** Transaction id of the submission an `unknown_outcome` error refers to, when known. */
167
+ txid?: string
168
+ }
169
+
170
+ /** Underlying errors before redaction, by the `QbitApiError` that carries their redacted copy. */
171
+ const unredactedCauses = new WeakMap<object, unknown>()
172
+
173
+ /** Brand shared by every copy of this class: `Symbol.for` keys are global across builds and realms. */
174
+ const QBIT_API_ERROR_BRAND = Symbol.for('qbit-js.QbitApiError')
175
+
176
+ export class QbitApiError extends Error {
177
+ readonly kind: QbitApiErrorKind
178
+ readonly status?: number
179
+ readonly url?: string
180
+ readonly retryAfterMs?: number
181
+ /**
182
+ * The underlying error. Errors the client creates attach a redacted copy,
183
+ * safe to log: see `QbitApiError.unredactedCause`.
184
+ */
185
+ readonly cause?: unknown
186
+ readonly responseBody?: string
187
+ readonly txid?: string
188
+
189
+ /**
190
+ * The underlying error of `error` as the fetch implementation threw it, or
191
+ * undefined. Its message, stack and properties can contain the full request
192
+ * URL, base-URL secrets included, so it is kept off the error object, where
193
+ * no logger, error reporter or serializer can reach it. Do not log it where
194
+ * the base URL is secret.
195
+ */
196
+ static unredactedCause(error: unknown): unknown {
197
+ return typeof error === 'object' && error !== null ? unredactedCauses.get(error) : undefined
198
+ }
199
+
200
+ /**
201
+ * Whether `value` is a `QbitApiError` (or a subclass instance) from any copy
202
+ * of `@qbit-oss/api`. `instanceof` only recognizes errors from the same copy,
203
+ * so it is false when an app loads both the CommonJS and the ESM build (or
204
+ * two installed versions) and the error comes from the other one. This checks
205
+ * a brand, `Symbol.for('qbit-js.QbitApiError')`, set on the class prototype
206
+ * instead. It identifies the class, not where the error came from: any code
207
+ * can set the brand. An error from another version can carry a `kind` this
208
+ * version does not list, so keep a default branch when switching on it.
209
+ */
210
+ static is(value: unknown): value is QbitApiError {
211
+ return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[QBIT_API_ERROR_BRAND] === true
212
+ }
213
+
214
+ constructor(kind: QbitApiErrorKind, message: string, options: QbitApiErrorOptions = {}) {
215
+ super(message)
216
+ this.name = 'QbitApiError'
217
+ this.kind = kind
218
+ if (options.status !== undefined) this.status = options.status
219
+ if (options.url !== undefined) this.url = options.url
220
+ if (options.retryAfterMs !== undefined) this.retryAfterMs = options.retryAfterMs
221
+ if (options.cause !== undefined) this.cause = options.cause
222
+ if (options.unredactedCause !== undefined) unredactedCauses.set(this, options.unredactedCause)
223
+ if (options.responseBody !== undefined) this.responseBody = options.responseBody
224
+ if (options.txid !== undefined) this.txid = options.txid
225
+ }
226
+ }
227
+
228
+ Object.defineProperty(QbitApiError.prototype, QBIT_API_ERROR_BRAND, { value: true })
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Client
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /**
235
+ * Fetch credential policy, the values of the DOM's `RequestCredentials`.
236
+ * Declared here so the published types compile without the DOM lib, which
237
+ * `@types/node` does not provide this name from.
238
+ */
239
+ export type QbitFetchCredentials = 'omit' | 'same-origin' | 'include'
240
+
241
+ export interface EsploraClientOptions {
242
+ /** Base URL for the esplora endpoint, e.g. `https://blockstream.info/testnet/api`. */
243
+ baseUrl: string
244
+ /**
245
+ * Accept a plain `http:` baseUrl on a non-loopback host (default false).
246
+ * Without TLS, request headers such as API keys and every response are
247
+ * exposed to the network. `localhost`, `127.0.0.0/8` and `[::1]` are always
248
+ * accepted.
249
+ */
250
+ allowInsecureHttp?: boolean
251
+ /**
252
+ * Custom fetch (defaults to global fetch). It should honour
253
+ * `redirect: 'manual'`; a reply it reports as `redirected`, or whose `url`
254
+ * is not the requested URL (another scheme, host, port, path or query), is
255
+ * refused, but only after the redirected request was sent. A `QbitApiError`
256
+ * it throws, from any copy of this package (see `QbitApiError.is`),
257
+ * propagates as it is, or as the `cause` of an `unknown_outcome` from a
258
+ * submission that may have been sent. One from another copy is judged by its
259
+ * public fields only, since that copy keeps the original cause and response
260
+ * body to itself: on a submission, its `network` error never proves that
261
+ * nothing was sent, and its response body never triggers the `/tx/push`
262
+ * fallback.
263
+ */
264
+ fetchFn?: typeof fetch
265
+ /** Fetch credential policy (default `omit`). */
266
+ credentials?: QbitFetchCredentials
267
+ /** Per-request timeout in ms (default 15000). */
268
+ timeoutMs?: number
269
+ /**
270
+ * Retry attempts for transient failures — network/timeout/5xx/429 on reads
271
+ * (default 2). Submissions only repeat attempts that provably never reached
272
+ * the server; see `broadcastTx`.
273
+ */
274
+ retries?: number
275
+ /** Base backoff in ms; grows exponentially per attempt (default 300). */
276
+ retryBaseDelayMs?: number
277
+ /**
278
+ * Longest server-requested `Retry-After` delay the client will sleep before
279
+ * retrying, in ms (default 30000, at most 2147483647). A longer request is not
280
+ * slept on: a read's 429/5xx (`rate_limit` / `http`) or a submission's 429
281
+ * (`rate_limit`) is thrown at once with `retryAfterMs` set. A submission's
282
+ * 5xx is never retried, so the cap does not apply to it; see `broadcastTx`.
283
+ */
284
+ maxRetryAfterMs?: number
285
+ /** Extra request headers. UDID/telemetry headers are intentionally unsupported. */
286
+ headers?: Record<string, string>
287
+ /**
288
+ * Sleep function (injectable for tests); defaults to an abortable setTimeout.
289
+ * Receives the call's signal; the client stops waiting on abort either way.
290
+ */
291
+ sleepFn?: (ms: number, signal?: AbortSignal) => Promise<void>
292
+ /** Cancels every in-flight and future call on this client when aborted. */
293
+ signal?: AbortSignal
294
+ }
295
+
296
+ export interface MempoolQbitClientOptions extends EsploraClientOptions {
297
+ /** Maximum combined-history requests fetched per call (default 256). */
298
+ maxAddressHistoryPages?: number
299
+ }
300
+
301
+ const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504])
302
+ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD'])
303
+ /** 5xx replies that mean the server did not process the request at all. */
304
+ const UNPROCESSED_SERVER_ERROR_STATUSES = new Set([501, 505, 511])
305
+ /** Error codes that only occur before a connection exists, so nothing was sent. */
306
+ const PRE_CONNECTION_ERROR_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'])
307
+ /** Route errors that are pre-send only from the connect syscall; later ones can follow a written request. */
308
+ const CONNECT_STAGE_ERROR_CODES = new Set(['EHOSTUNREACH', 'ENETUNREACH'])
309
+ /**
310
+ * String codes a backend relays when its own node call fails: Node system and
311
+ * internal errors (`ETIMEDOUT`, `ECONNRESET`, `ERR_STREAM_PREMATURE_CLOSE`),
312
+ * undici (`UND_ERR_*`) and http-parser (`HPE_*`) errors. Other strings are
313
+ * gateway or application codes that say nothing about the node call.
314
+ */
315
+ const TRANSPORT_ERROR_CODE_PATTERN = /^(?:E[A-Z0-9_]+|UND_ERR_[A-Z0-9_]+|HPE_[A-Z0-9_]+)$/
316
+ const TXID_PATTERN = /^[0-9a-f]{64}$/
317
+ const DEFAULT_MAX_ADDRESS_HISTORY_PAGES = 256
318
+ const DEFAULT_MAX_ADDRESS_HISTORY_TRANSACTIONS = 6_400
319
+ const DEFAULT_MAX_RETRY_AFTER_MS = 30_000
320
+ /** setTimeout stores its delay as a signed 32-bit int; larger values fire after ~1 ms. */
321
+ const MAX_TIMER_DELAY_MS = 2_147_483_647
322
+ const MAX_RETRIES = 10
323
+
324
+ const UNSAFE_BASE_URL_CHAR =
325
+ /[\s\p{Cc}\\?#]|(?![\u200c\u200d])[\p{Cf}\p{Default_Ignorable_Code_Point}]/u
326
+
327
+ function validBaseUrl(value: unknown, allowInsecureHttp: boolean | undefined): string {
328
+ // Paths are appended to the raw string, so a query or fragment would swallow
329
+ // them. new URL() silently strips whitespace and C0 controls, turns
330
+ // backslashes into slashes (`/api\` would request `/api//...`), and
331
+ // percent-encodes C1 controls (U+0080-U+009F), format characters and other
332
+ // default-ignorable code points (e.g. U+00AD, U+200B, U+FE0F, U+034F) into a
333
+ // different path, or drops them from the host.
334
+ // fetch rejects credentials. Other non-ASCII stays valid: IDN hosts map to
335
+ // punycode and path characters get their standard UTF-8 percent-encoding.
336
+ // ZWNJ/ZWJ (U+200C/U+200D) are exempt in the host because they are
337
+ // significant in some IDN labels (new URL() rejects them where IDNA does not
338
+ // allow them); in the path they are invisible and rejected like the rest.
339
+ // The parts are read with `schemeAndHost`, as React Native's URL getters
340
+ // throw before 0.80 and misread the host from 0.80; where the runtime's URL
341
+ // parser follows the URL Standard, it must also accept the URL.
342
+ const invalid = () =>
343
+ new QbitApiError(
344
+ 'http',
345
+ 'baseUrl must be an absolute http(s) URL without whitespace, control or format characters, backslashes, credentials, query or fragment'
346
+ )
347
+ if (typeof value !== 'string' || UNSAFE_BASE_URL_CHAR.test(value)) throw invalid()
348
+ if (followsUrlStandard() && standardHref(value) === undefined) throw invalid()
349
+ const url = schemeAndHost(value)
350
+ if (
351
+ !url ||
352
+ (url.protocol !== 'http:' && url.protocol !== 'https:') ||
353
+ !url.hostname ||
354
+ url.userinfo ||
355
+ !/^\d*$/.test(url.port) ||
356
+ Number(url.port) > 65535 ||
357
+ /%E2%80%8[CD]|[\u200c\u200d]/i.test(url.path)
358
+ ) {
359
+ throw invalid()
360
+ }
361
+ // Plain http exposes request headers (API keys) and every response to the
362
+ // network; loopback is exempt for local and regtest backends.
363
+ if (url.protocol === 'http:' && allowInsecureHttp !== true && !isLoopbackHostname(url.hostname)) {
364
+ throw new QbitApiError(
365
+ 'http',
366
+ 'baseUrl must use https unless its host is loopback; pass allowInsecureHttp: true to allow plain http'
367
+ )
368
+ }
369
+ return value.replace(/\/+$/, '')
370
+ }
371
+
372
+ function integerOption(
373
+ value: number | undefined,
374
+ defaultValue: number,
375
+ name: keyof EsploraClientOptions,
376
+ min: number,
377
+ max: number
378
+ ): number {
379
+ // Only an omitted option takes the default; an explicit null is invalid.
380
+ const option = value === undefined ? defaultValue : value
381
+ if (!Number.isInteger(option) || option < min || option > max) {
382
+ throw new QbitApiError('http', `${name} must be an integer from ${min} to ${max}`)
383
+ }
384
+ return option
385
+ }
386
+
387
+ function retryBaseDelayOption(value: number | undefined): number {
388
+ const delay = value === undefined ? 300 : value
389
+ if (typeof delay !== 'number' || !Number.isFinite(delay) || delay < 0 || delay > MAX_TIMER_DELAY_MS) {
390
+ throw new QbitApiError(
391
+ 'http',
392
+ `retryBaseDelayMs must be a finite number from 0 to ${MAX_TIMER_DELAY_MS}`
393
+ )
394
+ }
395
+ return delay
396
+ }
397
+
398
+ function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
399
+ return new Promise((resolve, reject) => {
400
+ if (signal?.aborted) {
401
+ reject(abortErrorFor(signal))
402
+ return
403
+ }
404
+ const onAbort = () => {
405
+ clearTimeout(timer)
406
+ reject(abortErrorFor(signal!))
407
+ }
408
+ const timer = setTimeout(() => {
409
+ signal?.removeEventListener('abort', onAbort)
410
+ resolve()
411
+ }, ms)
412
+ signal?.addEventListener('abort', onAbort, { once: true })
413
+ })
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Operation scope: one signal per public call, carried through every
418
+ // dependent request, retry, and backoff.
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /**
422
+ * Signals created by this module, always aborted with the `QbitApiError` the
423
+ * call rejects with. Any other signal belongs to a caller, whose reason is
424
+ * only ever a cause, even when it is an error this module returned earlier.
425
+ */
426
+ const internalSignals = new WeakSet<AbortSignal>()
427
+
428
+ function internalAbortController(): AbortController {
429
+ const controller = new AbortController()
430
+ internalSignals.add(controller.signal)
431
+ return controller
432
+ }
433
+
434
+ /** The `QbitApiError` an aborted signal stands for: a deadline or caller cancellation. */
435
+ function abortErrorFor(signal: AbortSignal): QbitApiError {
436
+ const reason: unknown = signal.reason
437
+ // `instanceof`, not `QbitApiError.is`: only this module aborts its internal
438
+ // signals, always with its own errors.
439
+ if (internalSignals.has(signal) && reason instanceof QbitApiError) return reason
440
+ return new QbitApiError('aborted', `request aborted by caller${formatErrorCause(reason)}`, {
441
+ cause: reason,
442
+ })
443
+ }
444
+
445
+ interface OperationScope {
446
+ signal: AbortSignal
447
+ dispose(): void
448
+ }
449
+
450
+ function openOperationScope(
451
+ sources: (AbortSignal | undefined)[],
452
+ deadlineMs: number | undefined
453
+ ): OperationScope {
454
+ if (
455
+ deadlineMs !== undefined &&
456
+ !(Number.isFinite(deadlineMs) && deadlineMs > 0 && deadlineMs <= MAX_TIMER_DELAY_MS)
457
+ ) {
458
+ throw new QbitApiError(
459
+ 'http',
460
+ `deadlineMs must be a positive number no greater than ${MAX_TIMER_DELAY_MS}`
461
+ )
462
+ }
463
+
464
+ const controller = internalAbortController()
465
+ const cleanups: (() => void)[] = []
466
+ const dispose = () => {
467
+ for (const cleanup of cleanups) cleanup()
468
+ cleanups.length = 0
469
+ }
470
+ const abort = (error: QbitApiError) => {
471
+ if (controller.signal.aborted) return
472
+ controller.abort(error)
473
+ dispose()
474
+ }
475
+
476
+ for (const source of sources) {
477
+ if (source === undefined) continue
478
+ if (source.aborted) {
479
+ abort(abortErrorFor(source))
480
+ break
481
+ }
482
+ const onAbort = () => abort(abortErrorFor(source))
483
+ source.addEventListener('abort', onAbort, { once: true })
484
+ cleanups.push(() => source.removeEventListener('abort', onAbort))
485
+ }
486
+ if (deadlineMs !== undefined && !controller.signal.aborted) {
487
+ const timer = setTimeout(
488
+ () =>
489
+ abort(new QbitApiError('timeout', `operation exceeded its ${deadlineMs}ms deadline`)),
490
+ deadlineMs
491
+ )
492
+ cleanups.push(() => clearTimeout(timer))
493
+ }
494
+
495
+ if (controller.signal.aborted) {
496
+ dispose()
497
+ throw abortErrorFor(controller.signal)
498
+ }
499
+ return { signal: controller.signal, dispose }
500
+ }
501
+
502
+ /** Settle with `promise`, or reject as soon as `signal` aborts, whichever is first. */
503
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
504
+ return new Promise<T>((resolve, reject) => {
505
+ const onAbort = () => reject(abortErrorFor(signal))
506
+ if (signal.aborted) onAbort()
507
+ else signal.addEventListener('abort', onAbort, { once: true })
508
+ promise.then(
509
+ value => {
510
+ signal.removeEventListener('abort', onAbort)
511
+ resolve(value)
512
+ },
513
+ (error: unknown) => {
514
+ signal.removeEventListener('abort', onAbort)
515
+ reject(error)
516
+ }
517
+ )
518
+ })
519
+ }
520
+
521
+ interface InflightRequest {
522
+ response: Promise<Response>
523
+ consumers: number
524
+ /** Aborts the shared request once every consumer has abandoned it. */
525
+ controller: AbortController
526
+ }
527
+
528
+ export class EsploraClient {
529
+ /** As configured, without trailing slashes. It can carry secrets: errors show `errorUrl(path)`. */
530
+ protected readonly baseUrl: string
531
+ private readonly fetchFn: typeof fetch
532
+ private readonly timeoutMs: number
533
+ private readonly retries: number
534
+ private readonly retryBaseDelayMs: number
535
+ private readonly maxRetryAfterMs: number
536
+ private readonly headers: Record<string, string>
537
+ private readonly credentials: QbitFetchCredentials
538
+ private readonly sleepFn: (ms: number, signal?: AbortSignal) => Promise<void>
539
+ private readonly signal: AbortSignal | undefined
540
+ /** In-flight GET de-dup: concurrent identical GETs share one request. */
541
+ private readonly inflight = new Map<string, InflightRequest>()
542
+
543
+ constructor(options: EsploraClientOptions) {
544
+ if (!options.baseUrl) {
545
+ throw new QbitApiError('http', 'EsploraClient requires a baseUrl')
546
+ }
547
+ const baseUrl = validBaseUrl(options.baseUrl, options.allowInsecureHttp)
548
+ const timeoutMs = integerOption(options.timeoutMs, 15000, 'timeoutMs', 1, MAX_TIMER_DELAY_MS)
549
+ const retries = integerOption(options.retries, 2, 'retries', 0, MAX_RETRIES)
550
+ const retryBaseDelayMs = retryBaseDelayOption(options.retryBaseDelayMs)
551
+ const maxRetryAfterMs = integerOption(
552
+ options.maxRetryAfterMs,
553
+ DEFAULT_MAX_RETRY_AFTER_MS,
554
+ 'maxRetryAfterMs',
555
+ 0,
556
+ MAX_TIMER_DELAY_MS
557
+ )
558
+ const fetchImpl = options.fetchFn ?? globalThis.fetch
559
+ if (typeof fetchImpl !== 'function') {
560
+ throw new QbitApiError('network', 'no fetch implementation available; pass options.fetchFn')
561
+ }
562
+ this.baseUrl = baseUrl
563
+ this.fetchFn = options.fetchFn ? fetchImpl : fetchImpl.bind(globalThis)
564
+ this.timeoutMs = timeoutMs
565
+ this.retries = retries
566
+ this.retryBaseDelayMs = retryBaseDelayMs
567
+ this.maxRetryAfterMs = maxRetryAfterMs
568
+ this.headers = { ...(options.headers ?? {}) }
569
+ this.credentials = options.credentials ?? 'omit'
570
+ this.sleepFn = options.sleepFn ?? defaultSleep
571
+ this.signal = options.signal
572
+ }
573
+
574
+ // --- esplora endpoints -------------------------------------------------
575
+
576
+ getAddressStats(address: string, options?: RequestOptions): Promise<EsploraAddressStats> {
577
+ return this.getJson<EsploraAddressStats>(`/address/${encodeURIComponent(address)}`, options)
578
+ }
579
+
580
+ getAddressUtxos(address: string, options?: RequestOptions): Promise<EsploraUtxo[]> {
581
+ return this.getJson<EsploraUtxo[]>(`/address/${encodeURIComponent(address)}/utxo`, options)
582
+ }
583
+
584
+ /**
585
+ * Confirmed transaction history for an address, newest first, 25 per page.
586
+ * Pass the last txid of the previous page as `afterTxid` to paginate.
587
+ */
588
+ getAddressTxs(
589
+ address: string,
590
+ afterTxid?: string,
591
+ options?: RequestOptions
592
+ ): Promise<EsploraTx[]> {
593
+ const suffix = afterTxid ? `/${encodeURIComponent(afterTxid)}` : ''
594
+ return this.getJson<EsploraTx[]>(
595
+ `/address/${encodeURIComponent(address)}/txs/chain${suffix}`,
596
+ options
597
+ )
598
+ }
599
+
600
+ /**
601
+ * Unconfirmed (mempool) transactions touching an address. electrs lists at
602
+ * most `--rest-default-max-mempool-txs` (default 50) of them, with no way to
603
+ * page further; `getAddressHistory` detects that cap.
604
+ */
605
+ getAddressMempoolTxs(address: string, options?: RequestOptions): Promise<EsploraTx[]> {
606
+ return this.getJson<EsploraTx[]>(
607
+ `/address/${encodeURIComponent(address)}/txs/mempool`,
608
+ options
609
+ )
610
+ }
611
+
612
+ /**
613
+ * Complete address history from a bounded, page-one scan. The confirmed
614
+ * chain route is exhausted explicitly; short pages are not assumed final.
615
+ *
616
+ * electrs answers a chain cursor it does not know (a lagging node behind a
617
+ * load balancer, or a reorg between pages) with HTTP 200 and `[]`, and
618
+ * `/txs/mempool` stops at 50 rows, so the scan alone cannot tell a short
619
+ * history from a complete one. The address stats are therefore read before
620
+ * and after the scan, each in a request of its own, and the history is
621
+ * returned only if both reads agree and it holds exactly
622
+ * `chain_stats.tx_count` confirmed and `mempool_stats.tx_count` mempool
623
+ * transactions. If the stats changed during the scan, the address is scanned
624
+ * once more; if they change again, or the counts differ, the call rejects
625
+ * with `invalid_cursor`. Stats without a non-negative safe-integer
626
+ * `tx_count` reject with `parse`. `maxPages` and `maxTransactions` cover
627
+ * both scans.
628
+ */
629
+ getAddressHistory(
630
+ address: string,
631
+ options: AddressHistoryOptions = {}
632
+ ): Promise<AddressHistory> {
633
+ return this.runOperation(options, signal => this.scanAddressHistory(address, options, signal))
634
+ }
635
+
636
+ private async scanAddressHistory(
637
+ address: string,
638
+ options: AddressHistoryOptions,
639
+ signal: AbortSignal
640
+ ): Promise<AddressHistory> {
641
+ const maxPages = positiveAddressHistoryLimit(
642
+ options.maxPages,
643
+ DEFAULT_MAX_ADDRESS_HISTORY_PAGES,
644
+ 'maxPages'
645
+ )
646
+ const maxTransactions = positiveAddressHistoryLimit(
647
+ options.maxTransactions,
648
+ DEFAULT_MAX_ADDRESS_HISTORY_TRANSACTIONS,
649
+ 'maxTransactions'
650
+ )
651
+ const budget = createAddressHistoryBudget()
652
+ return verifiedAddressHistory(
653
+ address,
654
+ 'esplora',
655
+ () => this.getJson<unknown>(`/address/${encodeURIComponent(address)}`, { signal }, false),
656
+ () => this.scanAddressHistoryOnce(address, maxPages, maxTransactions, budget, signal)
657
+ )
658
+ }
659
+
660
+ private async scanAddressHistoryOnce(
661
+ address: string,
662
+ maxPages: number,
663
+ maxTransactions: number,
664
+ budget: AddressHistoryBudget,
665
+ signal: AbortSignal
666
+ ): Promise<AddressHistory> {
667
+ // A rescan only gets the page fetches the first scan left.
668
+ if (budget.pageRequests >= maxPages) {
669
+ throw addressHistoryPageLimitError(address, maxPages, 'esplora')
670
+ }
671
+ const state = createAddressHistoryScanState()
672
+
673
+ const mempoolRows = reserveAddressHistoryTransactions(
674
+ budget,
675
+ await this.getAddressMempoolTxs(address, { signal }),
676
+ maxTransactions,
677
+ address,
678
+ 'esplora',
679
+ 'esplora mempool'
680
+ )
681
+ const mempoolTxs = assertAddressHistoryPage(mempoolRows, address, 'esplora mempool')
682
+ appendAddressHistoryTransactions(state, mempoolTxs, 'mempool', address, 'esplora')
683
+
684
+ let cursor: string | undefined
685
+ const seenCursors = new Set<string>()
686
+
687
+ while (true) {
688
+ budget.pageRequests += 1
689
+ const rows = reserveAddressHistoryTransactions(
690
+ budget,
691
+ await this.getAddressTxs(address, cursor, { signal }),
692
+ maxTransactions,
693
+ address,
694
+ 'esplora',
695
+ 'esplora confirmed'
696
+ )
697
+ if (rows.length === 0) break
698
+
699
+ if (budget.pageRequests === maxPages) {
700
+ throw addressHistoryPageLimitError(address, maxPages, 'esplora')
701
+ }
702
+ const txs = assertAddressHistoryPage(rows, address, 'esplora confirmed')
703
+ cursor = nextAddressHistoryCursor(txs, cursor, seenCursors, address, 'esplora')
704
+ appendAddressHistoryTransactions(state, txs, 'confirmed', address, 'esplora')
705
+ }
706
+
707
+ return { confirmed: state.confirmed, mempool: state.mempool }
708
+ }
709
+
710
+ getTx(txid: string, options?: RequestOptions): Promise<EsploraTx> {
711
+ return this.getJson<EsploraTx>(`/tx/${encodeURIComponent(txid)}`, options)
712
+ }
713
+
714
+ /**
715
+ * Raw transaction hex as the backend serializes it (with witness data when
716
+ * the transaction has any), in lowercase. The reply is checked before it is
717
+ * returned: it must be non-empty, even-length hex that decodes to one
718
+ * complete transaction whose txid (the double SHA-256 of its
719
+ * witness-stripped serialization, as qbit Core computes it) is `txid`,
720
+ * compared case-insensitively. The transaction must be encoded as qbit
721
+ * Core's decoder requires: every count and length in its shortest
722
+ * CompactSize form and at most 0x02000000, and no witness flag when every
723
+ * witness stack is empty. Anything else, including the empty `200`
724
+ * reply mempool.qbit.org serves, throws a `parse` `QbitApiError`, so a
725
+ * backend cannot hand back an empty string or another transaction. Spaces,
726
+ * tabs and line breaks around the hex are ignored. The txid does not commit
727
+ * to witness data, so a reply with altered or stripped witnesses still
728
+ * passes; compare the wtxid if you need them.
729
+ */
730
+ async getTxHex(txid: string, options?: RequestOptions): Promise<string> {
731
+ const path = `/tx/${encodeURIComponent(txid)}/hex`
732
+ const text = await this.getText(path, options)
733
+ const hex = trimAsciiWhitespace(text).toLowerCase()
734
+ const invalid = (reason: string) =>
735
+ new QbitApiError('parse', `transaction hex reply ${reason}`, {
736
+ url: this.errorUrl(path),
737
+ responseBody: responseBodyPreview(text, this.baseUrl),
738
+ })
739
+ if (hex === '') throw invalid('was empty')
740
+ if (hex.length % 2 !== 0 || !/^[0-9a-f]+$/.test(hex)) {
741
+ throw invalid(`was not even-length hex: ${quotedResponseText(text, this.baseUrl)}`)
742
+ }
743
+ const actual = tryTxidFromRawTx(hex)
744
+ if (actual === undefined) throw invalid('is not a well-formed transaction')
745
+ if (actual !== String(txid).toLowerCase()) throw invalid(`is transaction ${actual}, not the one requested`)
746
+ return hex
747
+ }
748
+
749
+ getTxStatus(txid: string, options?: RequestOptions): Promise<EsploraTxStatus> {
750
+ return this.getJson<EsploraTxStatus>(`/tx/${encodeURIComponent(txid)}/status`, options)
751
+ }
752
+
753
+ getFeeEstimates(options?: RequestOptions): Promise<EsploraFeeEstimates> {
754
+ return this.getJson<EsploraFeeEstimates>('/fee-estimates', options)
755
+ }
756
+
757
+ async getTipHeight(options?: RequestOptions): Promise<number> {
758
+ const text = await this.getText('/blocks/tip/height', options)
759
+ const height = Number(text.trim())
760
+ if (!Number.isInteger(height)) {
761
+ throw new QbitApiError('parse', `tip height was not an integer: ${quotedResponseText(text, this.baseUrl)}`, {
762
+ url: this.errorUrl('/blocks/tip/height'),
763
+ responseBody: responseBodyPreview(text, this.baseUrl),
764
+ })
765
+ }
766
+ return height
767
+ }
768
+
769
+ async getTipHash(options?: RequestOptions): Promise<string> {
770
+ return (await this.getText('/blocks/tip/hash', options)).trim()
771
+ }
772
+
773
+ /**
774
+ * Broadcast a raw transaction hex. Resolves with the txid once the node has
775
+ * the transaction, including when it reports the transaction as already in
776
+ * its mempool or chain. A submission is never re-sent after an attempt that
777
+ * may have reached the node: a timeout, an ambiguous network error, or a
778
+ * 5xx reply whose body carries no verdict from the node rejects with
779
+ * `unknown_outcome` carrying the txid so the caller can check
780
+ * `getTxStatus(txid)` before resubmitting the same hex. Only 429 replies and
781
+ * connection failures that happened before anything was sent are retried; a
782
+ * 429 whose `Retry-After` exceeds `maxRetryAfterMs` is not waited on and
783
+ * rejects at once as `rate_limit` with `retryAfterMs`, without a re-send.
784
+ * A redirect is never followed. A refused one (a 3xx, a browser opaque
785
+ * redirect, or a reply from a redirect the runtime followed) rejects with
786
+ * `unknown_outcome` too, with the `http` refusal as `cause`: the POST
787
+ * reached the backend, which may have accepted it before redirecting, and
788
+ * a runtime that followed the redirect may have re-sent it.
789
+ * Coded node rejections fail closed as `http` errors whatever the HTTP
790
+ * status, since qbit Core's own JSON-RPC server answers 500 for every RPC
791
+ * error and a backend or proxy may relay that status. When no txid
792
+ * can be derived from the hex and none is supplied, a 2xx reply resolves only
793
+ * if its body is itself a 64-hex txid; any other 2xx body is `unknown_outcome`.
794
+ * Hex `txidFromRawTx` cannot read, including encodings qbit Core refuses to
795
+ * decode, is still sent as it is, and the node gives the verdict.
796
+ * A malformed `rawTxHex` argument or an `options.txid` that is not a 64-hex
797
+ * transaction id matching the hex throws a `TypeError` before anything is sent.
798
+ *
799
+ * `options.signal` / `options.deadlineMs` bound the whole call. An abort or
800
+ * expired deadline before the POST is sent rejects with `aborted` /
801
+ * `timeout`; once the POST may have been sent (in flight or while its reply
802
+ * is read) it rejects with `unknown_outcome`, with that error as `cause`.
803
+ * A reply whose body stalls past `timeoutMs` or cannot be read is
804
+ * `unknown_outcome` too. A 429 reply is the exception throughout: it proves
805
+ * nothing was submitted, so one whose body is lost is retried (then
806
+ * `rate_limit`), and an abort or deadline during its body is definite.
807
+ */
808
+ async broadcastTx(rawTxHex: string, options?: BroadcastTxOptions): Promise<string> {
809
+ return this.submitTx('/tx', rawTxHex, resolveBroadcastTxid(rawTxHex, options), options)
810
+ }
811
+
812
+ // --- adapters (clean, wallet-friendly shapes) --------------------------
813
+
814
+ /** Confirmed + mempool balance for an address, in satoshis. */
815
+ async getAddressBalance(address: string, options?: RequestOptions): Promise<AddressBalance> {
816
+ return addressBalanceFromStats(await this.getAddressStats(address, options))
817
+ }
818
+
819
+ /** Fee-rate tiers (sat/vB), ascending by confirmation target. */
820
+ async getFeeTiers(options?: RequestOptions): Promise<FeeRateTier[]> {
821
+ return feeTiersFromEstimates(await this.getFeeEstimates(options))
822
+ }
823
+
824
+ // --- internals ---------------------------------------------------------
825
+
826
+ /**
827
+ * Run one public call under a single signal that combines the client's
828
+ * signal, the caller's signal, and the caller's deadline. Pass `{ signal }`
829
+ * to dependent calls so they share the same cancellation and budget.
830
+ */
831
+ protected async runOperation<T>(
832
+ options: RequestOptions | undefined,
833
+ run: (signal: AbortSignal) => Promise<T>
834
+ ): Promise<T> {
835
+ const scope = openOperationScope([this.signal, options?.signal], options?.deadlineMs)
836
+ try {
837
+ return await run(scope.signal)
838
+ } finally {
839
+ scope.dispose()
840
+ }
841
+ }
842
+
843
+ /**
844
+ * GET and parse JSON. With `dedup` false the request is sent on its own
845
+ * instead of joining an identical one already in flight, whose reply may
846
+ * predate requests this call has made since.
847
+ */
848
+ protected getJson<T>(path: string, options?: RequestOptions, dedup = true): Promise<T> {
849
+ return this.runOperation(options, async signal => {
850
+ const response = await this.request(path, { method: 'GET', dedup }, { signal })
851
+ const url = this.errorUrl(path)
852
+ const text = await readBody(response, url, this.baseUrl, signal)
853
+ if (!response.ok) throw httpError(response, text, url, this.baseUrl)
854
+ try {
855
+ return JSON.parse(text) as T
856
+ } catch (cause) {
857
+ // V8's JSON.parse error quotes the first characters of the body, and a
858
+ // token cut short there escapes redaction, so the cause is a generic
859
+ // SyntaxError; the original stays behind QbitApiError.unredactedCause().
860
+ throw new QbitApiError('parse', `failed to parse JSON from ${path}`, {
861
+ url,
862
+ responseBody: responseBodyPreview(text, this.baseUrl),
863
+ cause: new SyntaxError('response body is not valid JSON'),
864
+ unredactedCause: cause,
865
+ })
866
+ }
867
+ })
868
+ }
869
+
870
+ protected getText(path: string, options?: RequestOptions): Promise<string> {
871
+ return this.runOperation(options, async signal => {
872
+ const response = await this.request(path, { method: 'GET', dedup: true }, { signal })
873
+ const url = this.errorUrl(path)
874
+ const text = await readBody(response, url, this.baseUrl, signal)
875
+ if (!response.ok) throw httpError(response, text, url, this.baseUrl)
876
+ return text
877
+ })
878
+ }
879
+
880
+ /**
881
+ * POST a transaction submission under the submission retry policy and
882
+ * classify the node's reply. `txid` identifies the submission in
883
+ * `unknown_outcome` errors and is the result for already-known replies.
884
+ * An abort or deadline once the POST may have been sent, including while
885
+ * the reply body is read, is an `unknown_outcome` too, unless the reply is
886
+ * a 429.
887
+ */
888
+ protected submitTx(
889
+ path: string,
890
+ body: string,
891
+ txid: string | undefined,
892
+ options?: RequestOptions
893
+ ): Promise<string> {
894
+ return this.runOperation(options, async signal => {
895
+ const url = `${this.baseUrl}${path}`
896
+ const errorUrl = this.errorUrl(path)
897
+ const response = await this.requestWithRetry(
898
+ url,
899
+ { method: 'POST', body, headers: { 'content-type': 'text/plain' } },
900
+ signal,
901
+ txid
902
+ )
903
+ let text: string
904
+ try {
905
+ text = (await readBody(response, errorUrl, this.baseUrl, signal)).trim()
906
+ } catch (cause) {
907
+ // A 429 proves nothing was submitted, even when the call is cut short here.
908
+ if (response.status === 429 && signal.aborted) throw abortErrorFor(signal)
909
+ throw unknownOutcomeError(errorUrl, txid, `HTTP ${response.status} reply body could not be read`, {
910
+ status: response.status,
911
+ cause,
912
+ })
913
+ }
914
+ const detail = responseBodyPreview(text, this.baseUrl)
915
+ const reply = httpReplyMessage(response.status, errorUrl, detail)
916
+ if (response.ok) {
917
+ // Only a reply that echoes the txid proves the node took this transaction.
918
+ // Without a txid to compare against, the body must itself be a txid: a
919
+ // gateway may answer 2xx with an HTML page or an empty body after the
920
+ // node accepted the submission, so anything else is an unknown outcome.
921
+ const echoed = text.toLowerCase()
922
+ if (txid === undefined) {
923
+ if (TXID_PATTERN.test(echoed)) return echoed
924
+ } else if (echoed === txid) {
925
+ return txid
926
+ }
927
+ const reason = txid === undefined ? 'reply is not a txid' : 'reply did not echo the txid'
928
+ throw unknownOutcomeError(errorUrl, txid, `${reason} (${reply})`, {
929
+ status: response.status,
930
+ responseBody: detail,
931
+ })
932
+ }
933
+ if (UNPROCESSED_SERVER_ERROR_STATUSES.has(response.status)) throw httpError(response, text, errorUrl, this.baseUrl)
934
+ // The body is read before the status is judged: qbit Core's JSON-RPC server
935
+ // answers HTTP 500 for every RPC error (src/httprpc.cpp, JSONErrorReply),
936
+ // so a relayed envelope carrying the node's verdict can arrive as a 5xx.
937
+ const rejection = parseBroadcastReply(text)
938
+ if (isAlreadyKnownBroadcastReply(rejection)) {
939
+ if (txid !== undefined) return txid
940
+ throw unknownOutcomeError(errorUrl, undefined, `node already knows the transaction: ${reply}`, {
941
+ status: response.status,
942
+ responseBody: detail,
943
+ })
944
+ }
945
+ if (response.status >= 500) {
946
+ // A 5xx alone says the server failed, not whether the node saw the
947
+ // transaction; only the node's own rejection in the body settles it.
948
+ if (isDefiniteNodeRejection(rejection)) throw httpError(response, text, errorUrl, this.baseUrl)
949
+ throw unknownOutcomeError(errorUrl, txid, reply, { status: response.status, responseBody: detail })
950
+ }
951
+ if (isRelayedTransportFailure(rejection)) {
952
+ throw unknownOutcomeError(errorUrl, txid, `backend lost its node connection (${reply})`, {
953
+ status: response.status,
954
+ responseBody: detail,
955
+ })
956
+ }
957
+ throw httpError(response, text, errorUrl, this.baseUrl)
958
+ })
959
+ }
960
+
961
+ protected request(
962
+ path: string,
963
+ init: { method: string; body?: string; headers?: Record<string, string>; dedup: boolean },
964
+ options?: RequestOptions
965
+ ): Promise<Response> {
966
+ return this.runOperation(options, signal => {
967
+ const url = `${this.baseUrl}${path}`
968
+ return init.dedup
969
+ ? this.requestShared(url, init, signal)
970
+ : this.requestWithRetry(url, init, signal)
971
+ })
972
+ }
973
+
974
+ /**
975
+ * Concurrent identical GETs share one request. One caller giving up only
976
+ * stops waiting; the shared request is aborted once every caller has left.
977
+ */
978
+ private async requestShared(
979
+ url: string,
980
+ init: { method: string; headers?: Record<string, string> },
981
+ signal: AbortSignal
982
+ ): Promise<Response> {
983
+ let pending = this.inflight.get(url)
984
+ if (pending === undefined) {
985
+ const controller = internalAbortController()
986
+ pending = {
987
+ response: this.requestWithRetry(url, init, controller.signal),
988
+ consumers: 0,
989
+ controller,
990
+ }
991
+ this.inflight.set(url, pending)
992
+ }
993
+ pending.consumers += 1
994
+
995
+ let response: Response
996
+ try {
997
+ response = await raceAbort(pending.response, signal)
998
+ } catch (error) {
999
+ pending.consumers -= 1
1000
+ if (pending.consumers === 0) {
1001
+ if (this.inflight.get(url) === pending) this.inflight.delete(url)
1002
+ if (signal.aborted) {
1003
+ const errorUrl = redactBaseUrl(url, this.baseUrl)
1004
+ pending.controller.abort(new QbitApiError('aborted', `request abandoned: ${errorUrl}`, { url: errorUrl }))
1005
+ // If the response already arrived, nobody will read its body.
1006
+ pending.response.then(discardResponseBody, () => undefined)
1007
+ }
1008
+ }
1009
+ throw error
1010
+ }
1011
+
1012
+ pending.consumers -= 1
1013
+ // The last waiter consumes the original body; earlier waiters receive clones.
1014
+ if (pending.consumers === 0) {
1015
+ if (this.inflight.get(url) === pending) this.inflight.delete(url)
1016
+ return response
1017
+ }
1018
+ return response.clone()
1019
+ }
1020
+
1021
+ /**
1022
+ * Idempotent requests (GET/HEAD) retry on network errors, timeouts, and
1023
+ * transient statuses. Any other method is a submission: an attempt is only
1024
+ * repeated when it provably never reached the server (429, or a connection
1025
+ * failure before anything was sent), and an attempt that may have been
1026
+ * processed fails with `unknown_outcome` instead of being re-sent; that
1027
+ * includes an abort or deadline that fires while a submission is in flight,
1028
+ * and a refused redirect. `txid` labels that error when the submission is a
1029
+ * transaction.
1030
+ */
1031
+ private async requestWithRetry(
1032
+ url: string,
1033
+ init: { method: string; body?: string; headers?: Record<string, string> },
1034
+ signal: AbortSignal,
1035
+ txid?: string
1036
+ ): Promise<Response> {
1037
+ const idempotent = IDEMPOTENT_METHODS.has(init.method.toUpperCase())
1038
+ const errorUrl = redactBaseUrl(url, this.baseUrl)
1039
+ let lastError: QbitApiError | undefined
1040
+ for (let attempt = 0; attempt <= this.retries; attempt += 1) {
1041
+ // Every earlier attempt was provably unsent (or a read), so an abort or
1042
+ // deadline before this one starts is a definite `aborted` / `timeout`.
1043
+ if (signal.aborted) throw abortErrorFor(signal)
1044
+ const reply: { status?: number; retryAfterMs?: number; refusedRedirect?: true } = {}
1045
+ let response: Response
1046
+ try {
1047
+ response = await this.fetchOnce(url, init, signal, reply)
1048
+ } catch (error) {
1049
+ // Another copy's QbitApiError (from a custom fetchFn) passes through too.
1050
+ lastError =
1051
+ QbitApiError.is(error)
1052
+ ? error
1053
+ : new QbitApiError('network', redactBaseUrl(String(error), this.baseUrl), {
1054
+ url: errorUrl,
1055
+ ...causeOptions(error, this.baseUrl),
1056
+ })
1057
+ // A refused redirect is never followed or retried. A submission's POST
1058
+ // still reached the origin, which may have accepted it before
1059
+ // redirecting (303), and a runtime that followed the redirect itself
1060
+ // may have re-sent it to the target (307/308).
1061
+ if (!idempotent && reply.refusedRedirect) {
1062
+ throw unknownOutcomeError(errorUrl, txid, lastError.message, {
1063
+ ...(lastError.status === undefined ? {} : { status: lastError.status }),
1064
+ cause: lastError,
1065
+ })
1066
+ }
1067
+ // fetchOnce reads the body, so a submission reply can arrive and then stall,
1068
+ // fail mid-read, or be cut short by an abort or the deadline. A 429 still
1069
+ // proves the submission was not processed; any other reply may follow an
1070
+ // accepted POST.
1071
+ if (!idempotent && reply.status === 429) {
1072
+ if (signal.aborted) throw abortErrorFor(signal)
1073
+ const { retryAfterMs } = reply
1074
+ if (attempt < this.retries && (retryAfterMs === undefined || retryAfterMs <= this.maxRetryAfterMs)) {
1075
+ await this.sleep(retryAfterMs ?? this.backoffMs(attempt), signal)
1076
+ continue
1077
+ }
1078
+ throw new QbitApiError('rate_limit', `HTTP 429 from ${errorUrl} (reply body could not be read)`, {
1079
+ status: 429,
1080
+ url: errorUrl,
1081
+ cause: lastError,
1082
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
1083
+ })
1084
+ }
1085
+ if (!idempotent && reply.status !== undefined) {
1086
+ throw unknownOutcomeError(errorUrl, txid, `HTTP ${reply.status} reply body could not be read`, {
1087
+ status: reply.status,
1088
+ cause: lastError,
1089
+ })
1090
+ }
1091
+ // A read whose transient reply (429/5xx) fails mid-body is retried like
1092
+ // the status itself, honouring Retry-After within the cap; the final
1093
+ // attempt's failure surfaces as before. A stalled body is a timeout and
1094
+ // takes the timeout path below.
1095
+ if (
1096
+ idempotent &&
1097
+ lastError.kind === 'parse' &&
1098
+ reply.status !== undefined &&
1099
+ TRANSIENT_STATUSES.has(reply.status)
1100
+ ) {
1101
+ const { retryAfterMs } = reply
1102
+ const overCap = retryAfterMs !== undefined && retryAfterMs > this.maxRetryAfterMs
1103
+ if (attempt < this.retries && !overCap && !signal.aborted) {
1104
+ await this.sleep(retryAfterMs ?? this.backoffMs(attempt), signal)
1105
+ continue
1106
+ }
1107
+ throw lastError
1108
+ }
1109
+ if (lastError.kind !== 'network' && lastError.kind !== 'timeout' && lastError.kind !== 'aborted') {
1110
+ throw lastError
1111
+ }
1112
+ // An abort or deadline here fired while the attempt was in flight, so a
1113
+ // submission may already have reached the server, like a timeout.
1114
+ // Judge the original fetch error: the public `cause` is a depth-limited
1115
+ // redacted copy that can drop a nested failure meaning the POST was sent.
1116
+ // Another copy of this package keeps that original where this one cannot
1117
+ // read it, so a `network` error from that copy never counts as unsent.
1118
+ const unsent =
1119
+ idempotent ||
1120
+ (lastError.kind === 'network' &&
1121
+ lastError instanceof QbitApiError &&
1122
+ isPreConnectionFailure(QbitApiError.unredactedCause(lastError) ?? lastError))
1123
+ if (!unsent) {
1124
+ // A custom fetchFn's own QbitApiError is not redacted; its message is, where quoted here.
1125
+ const reason = redactBaseUrl(String(lastError.message), this.baseUrl)
1126
+ throw unknownOutcomeError(errorUrl, txid, reason, { cause: lastError })
1127
+ }
1128
+ if (lastError.kind !== 'aborted' && attempt < this.retries && !signal.aborted) {
1129
+ await this.sleep(this.backoffMs(attempt), signal)
1130
+ continue
1131
+ }
1132
+ throw lastError
1133
+ }
1134
+
1135
+ const retryable = idempotent ? TRANSIENT_STATUSES.has(response.status) : response.status === 429
1136
+ if (retryable && attempt < this.retries) {
1137
+ const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
1138
+ // Never park the request (and every de-duplicated waiter) on an
1139
+ // unbounded server-chosen delay: hand the error, with retryAfterMs, to the caller.
1140
+ if (retryAfterMs !== undefined && retryAfterMs > this.maxRetryAfterMs) return response
1141
+ // Never wait on cleanup: a body whose cancel never settles must not
1142
+ // hold the call past an abort or deadline.
1143
+ void discardResponseBody(response)
1144
+ await this.sleep(retryAfterMs ?? this.backoffMs(attempt), signal)
1145
+ continue
1146
+ }
1147
+ return response
1148
+ }
1149
+ // Unreachable in practice, but satisfies the type checker.
1150
+ throw lastError ?? new QbitApiError('network', `request failed: ${errorUrl}`, { url: errorUrl })
1151
+ }
1152
+
1153
+ /** Backoff that rejects as soon as `signal` aborts, even if `sleepFn` ignores it. */
1154
+ private async sleep(ms: number, signal: AbortSignal): Promise<void> {
1155
+ if (signal.aborted) throw abortErrorFor(signal)
1156
+ await raceAbort(
1157
+ Promise.resolve().then(() => this.sleepFn(ms, signal)),
1158
+ signal
1159
+ )
1160
+ }
1161
+
1162
+ /**
1163
+ * One attempt under one timeout that spans the headers and the whole body,
1164
+ * also bounded by the call's signal until the body is buffered. Every
1165
+ * response it returns is fully buffered, whatever its status, so no caller
1166
+ * (retry loop, de-duplicated clone, or error path) can block on the network
1167
+ * after the timer is cleared. A body that misses the attempt timeout fails
1168
+ * the attempt as `timeout`, even for a status that would otherwise be
1169
+ * retried; an abort or call deadline, before or after the headers, rejects
1170
+ * with the call's `aborted` / `timeout` error. `reply.status` is set once
1171
+ * the headers arrive. A redirect is refused before its body is read, which
1172
+ * is cancelled instead, and `reply.refusedRedirect` is set; see
1173
+ * `refuseRedirect`.
1174
+ */
1175
+ private async fetchOnce(
1176
+ url: string,
1177
+ init: { method: string; body?: string; headers?: Record<string, string> },
1178
+ signal: AbortSignal,
1179
+ reply: { status?: number; retryAfterMs?: number; refusedRedirect?: true }
1180
+ ): Promise<Response> {
1181
+ if (signal.aborted) throw abortErrorFor(signal)
1182
+ const controller = new AbortController()
1183
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs)
1184
+ // The operation signal bounds each attempt: its timeout is effectively
1185
+ // min(timeoutMs, time left before the call's deadline).
1186
+ const onAbort = () => controller.abort(signal.reason)
1187
+ signal.addEventListener('abort', onAbort, { once: true })
1188
+ const requestInit: RequestInit = {
1189
+ method: init.method,
1190
+ headers: { ...this.headers, ...(init.headers ?? {}) },
1191
+ mode: 'cors',
1192
+ redirect: 'manual',
1193
+ credentials: this.credentials,
1194
+ signal: controller.signal,
1195
+ }
1196
+ if (init.body !== undefined) requestInit.body = init.body
1197
+ let pending: Promise<Response> | undefined
1198
+ try {
1199
+ let response: Response
1200
+ try {
1201
+ pending = this.fetchFn(url, requestInit)
1202
+ response = await raceAbort(pending, signal)
1203
+ } catch (cause) {
1204
+ if (signal.aborted) {
1205
+ // A fetch that ignores its signal may still deliver a response nobody reads.
1206
+ pending?.then(discardResponseBody, () => undefined)
1207
+ throw abortErrorFor(signal)
1208
+ }
1209
+ if (QbitApiError.is(cause)) throw cause // from a custom fetchFn, built by any copy of this package
1210
+ if (isAbortError(cause)) throw this.timeoutError(url, cause)
1211
+ // fetch implementations may quote the request URL in their own messages.
1212
+ const errorUrl = redactBaseUrl(url, this.baseUrl)
1213
+ throw new QbitApiError('network', `network error: ${errorUrl}${redactBaseUrl(formatErrorCause(cause), this.baseUrl)}`, {
1214
+ url: errorUrl,
1215
+ ...causeOptions(cause, this.baseUrl),
1216
+ })
1217
+ }
1218
+ // Checked on the raw reply, since a buffered copy loses `type` and
1219
+ // `redirected`, and before `reply.status` is set, so the retry loop
1220
+ // never retries the refusal.
1221
+ try {
1222
+ refuseRedirect(response, url, this.baseUrl)
1223
+ } catch (refusal) {
1224
+ reply.refusedRedirect = true
1225
+ throw refusal
1226
+ }
1227
+ reply.status = response.status
1228
+ const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
1229
+ if (retryAfterMs !== undefined) reply.retryAfterMs = retryAfterMs
1230
+
1231
+ let text: string
1232
+ try {
1233
+ text = await readBodyUntilAborted(response, controller.signal)
1234
+ } catch (cause) {
1235
+ // The call signal also aborts `controller`, so only an abort it did not
1236
+ // cause is this attempt's own timeout.
1237
+ if (signal.aborted) throw abortErrorFor(signal)
1238
+ if (controller.signal.aborted) throw this.timeoutError(url, cause, response.status)
1239
+ throw bodyReadError(response, cause, redactBaseUrl(url, this.baseUrl), this.baseUrl)
1240
+ }
1241
+ return bufferedResponse(response, text)
1242
+ } finally {
1243
+ clearTimeout(timer)
1244
+ signal.removeEventListener('abort', onAbort)
1245
+ }
1246
+ }
1247
+
1248
+ /** `status` is set when the headers arrived and the body missed the deadline. */
1249
+ private timeoutError(url: string, cause: unknown, status?: number): QbitApiError {
1250
+ // fetch implementations may quote the request URL in their own messages.
1251
+ const errorUrl = redactBaseUrl(url, this.baseUrl)
1252
+ return new QbitApiError(
1253
+ 'timeout',
1254
+ `request timed out after ${this.timeoutMs}ms: ${errorUrl}${redactBaseUrl(formatErrorCause(cause), this.baseUrl)}`,
1255
+ { url: errorUrl, ...causeOptions(cause, this.baseUrl), ...(status === undefined ? {} : { status }) }
1256
+ )
1257
+ }
1258
+
1259
+ private backoffMs(attempt: number): number {
1260
+ return Math.min(this.retryBaseDelayMs * 2 ** attempt, MAX_TIMER_DELAY_MS)
1261
+ }
1262
+
1263
+ /** The request URL for `path` as errors show it: see `redactedBaseUrl`. */
1264
+ protected errorUrl(path: string): string {
1265
+ return `${redactedBaseUrl(this.baseUrl)}${path}`
1266
+ }
1267
+ }
1268
+
1269
+ const MEMPOOL_QBIT_ADDRESS_PAGE_SIZE = 10
1270
+
1271
+ /**
1272
+ * Adapter for mempool-qbit's public Node API (`/api/v1` prefix). Most routes
1273
+ * match Esplora, but address pagination and fees use mempool-style shapes.
1274
+ */
1275
+ export class MempoolQbitClient extends EsploraClient {
1276
+ private readonly maxAddressHistoryPages: number
1277
+
1278
+ constructor(options: MempoolQbitClientOptions) {
1279
+ super(options)
1280
+ const maxAddressHistoryPages =
1281
+ options.maxAddressHistoryPages ?? DEFAULT_MAX_ADDRESS_HISTORY_PAGES
1282
+ if (!Number.isSafeInteger(maxAddressHistoryPages) || maxAddressHistoryPages < 1) {
1283
+ throw new QbitApiError('http', 'maxAddressHistoryPages must be a positive integer')
1284
+ }
1285
+ this.maxAddressHistoryPages = maxAddressHistoryPages
1286
+ }
1287
+
1288
+ /**
1289
+ * Complete address history from one bounded traversal of mempool-qbit's
1290
+ * combined endpoint. Page size is intentionally not assumed.
1291
+ *
1292
+ * The result is checked against the address stats, read before and after
1293
+ * the traversal, as `EsploraClient.getAddressHistory` describes, and a
1294
+ * traversal during which they changed is repeated once. qbit-electrs
1295
+ * answers a cursor that is the last mempool row with `[]` instead of the
1296
+ * confirmed rows that follow, so an address whose mempool rows fill whole
1297
+ * pages (50 each by default) rejects with `invalid_cursor` while it has
1298
+ * confirmed history, rather than returning none.
1299
+ */
1300
+ override getAddressHistory(
1301
+ address: string,
1302
+ options: AddressHistoryOptions = {}
1303
+ ): Promise<AddressHistory> {
1304
+ return this.runOperation(options, signal =>
1305
+ this.scanCombinedAddressHistory(address, options, signal)
1306
+ )
1307
+ }
1308
+
1309
+ private async scanCombinedAddressHistory(
1310
+ address: string,
1311
+ options: AddressHistoryOptions,
1312
+ signal: AbortSignal
1313
+ ): Promise<AddressHistory> {
1314
+ const requestedMaxPages = positiveAddressHistoryLimit(
1315
+ options.maxPages,
1316
+ this.maxAddressHistoryPages,
1317
+ 'maxPages'
1318
+ )
1319
+ const maxPages = Math.min(requestedMaxPages, this.maxAddressHistoryPages)
1320
+ const maxTransactions = positiveAddressHistoryLimit(
1321
+ options.maxTransactions,
1322
+ DEFAULT_MAX_ADDRESS_HISTORY_TRANSACTIONS,
1323
+ 'maxTransactions'
1324
+ )
1325
+ const budget = createAddressHistoryBudget()
1326
+ return verifiedAddressHistory(
1327
+ address,
1328
+ 'mempool-qbit',
1329
+ () => this.getJson<unknown>(`/address/${encodeURIComponent(address)}`, { signal }, false),
1330
+ () => this.scanCombinedAddressHistoryOnce(address, maxPages, maxTransactions, budget, signal)
1331
+ )
1332
+ }
1333
+
1334
+ private async scanCombinedAddressHistoryOnce(
1335
+ address: string,
1336
+ maxPages: number,
1337
+ maxTransactions: number,
1338
+ budget: AddressHistoryBudget,
1339
+ signal: AbortSignal
1340
+ ): Promise<AddressHistory> {
1341
+ // A rescan only gets the page fetches the first scan left.
1342
+ if (budget.pageRequests >= maxPages) {
1343
+ throw this.addressHistoryPaginationLimitError(address, maxPages)
1344
+ }
1345
+ const state = createAddressHistoryScanState()
1346
+ let cursor: string | undefined
1347
+ const seenCursors = new Set<string>()
1348
+
1349
+ while (true) {
1350
+ budget.pageRequests += 1
1351
+ const suffix = cursor ? `?after_txid=${encodeURIComponent(cursor)}` : ''
1352
+ const rows = reserveAddressHistoryTransactions(
1353
+ budget,
1354
+ await this.getJson<unknown>(`/address/${encodeURIComponent(address)}/txs${suffix}`, {
1355
+ signal,
1356
+ }),
1357
+ maxTransactions,
1358
+ address,
1359
+ 'mempool-qbit',
1360
+ 'mempool-qbit combined'
1361
+ )
1362
+ if (rows.length === 0) break
1363
+
1364
+ if (budget.pageRequests === maxPages) {
1365
+ throw this.addressHistoryPaginationLimitError(address, maxPages)
1366
+ }
1367
+ const txs = assertAddressHistoryPage(rows, address, 'mempool-qbit combined')
1368
+ cursor = nextAddressHistoryCursor(txs, cursor, seenCursors, address, 'mempool-qbit')
1369
+ appendAddressHistoryTransactions(state, txs, 'combined', address, 'mempool-qbit')
1370
+ }
1371
+
1372
+ return { confirmed: state.confirmed, mempool: state.mempool }
1373
+ }
1374
+
1375
+ /**
1376
+ * Confirmed transaction history for an address, newest first.
1377
+ * mempool-qbit paginates combined history with `?after_txid=...` and sorts
1378
+ * mempool entries first, so full mempool-only pages must still advance.
1379
+ * Without `afterTxid`, an empty page right after a mempool row is checked
1380
+ * against the address stats: qbit-electrs answers that cursor with `[]`
1381
+ * instead of the confirmed rows that follow, so the call rejects with
1382
+ * `invalid_cursor` if the stats count more confirmed transactions than it
1383
+ * found.
1384
+ */
1385
+ override getAddressTxs(
1386
+ address: string,
1387
+ afterTxid?: string,
1388
+ options?: RequestOptions
1389
+ ): Promise<EsploraTx[]> {
1390
+ return this.runOperation(options, signal => this.scanAddressTxs(address, afterTxid, signal))
1391
+ }
1392
+
1393
+ private async scanAddressTxs(
1394
+ address: string,
1395
+ afterTxid: string | undefined,
1396
+ signal: AbortSignal
1397
+ ): Promise<EsploraTx[]> {
1398
+ const confirmedTxs: EsploraTx[] = []
1399
+ let cursor: string | undefined
1400
+ let continuationFound = afterTxid === undefined
1401
+ let cursorIsMempoolTx = false
1402
+ const seenCursors = new Set<string>()
1403
+ const seenTxids = new Set<string>()
1404
+ let pageCount = 0
1405
+
1406
+ while (confirmedTxs.length < 25) {
1407
+ if (pageCount >= this.maxAddressHistoryPages) {
1408
+ throw this.addressHistoryPaginationLimitError(address)
1409
+ }
1410
+ pageCount += 1
1411
+ const suffix = cursor ? `?after_txid=${encodeURIComponent(cursor)}` : ''
1412
+ const txs = await this.getJson<EsploraTx[]>(
1413
+ `/address/${encodeURIComponent(address)}/txs${suffix}`,
1414
+ { signal }
1415
+ )
1416
+ if (txs.length === 0) {
1417
+ if (!continuationFound) throw this.invalidAddressHistoryCursorError(address, afterTxid!)
1418
+ if (afterTxid === undefined && cursorIsMempoolTx) {
1419
+ await this.assertConfirmedHistoryReached(address, confirmedTxs.length, signal)
1420
+ }
1421
+ break
1422
+ }
1423
+
1424
+ for (const tx of txs) {
1425
+ if (!continuationFound) {
1426
+ if (isConfirmedTx(tx)) seenTxids.add(tx.txid)
1427
+ if (tx.txid !== afterTxid) continue
1428
+ if (!isConfirmedTx(tx)) {
1429
+ throw this.invalidAddressHistoryCursorError(address, afterTxid)
1430
+ }
1431
+ continuationFound = true
1432
+ continue
1433
+ }
1434
+ if (isConfirmedTx(tx) && !seenTxids.has(tx.txid)) {
1435
+ confirmedTxs.push(tx)
1436
+ seenTxids.add(tx.txid)
1437
+ }
1438
+ }
1439
+
1440
+ const nextCursor = txs[txs.length - 1]?.txid
1441
+ if (confirmedTxs.length >= 25) break
1442
+ if (txs.length < MEMPOOL_QBIT_ADDRESS_PAGE_SIZE) {
1443
+ if (!continuationFound) throw this.invalidAddressHistoryCursorError(address, afterTxid!)
1444
+ break
1445
+ }
1446
+ if (!nextCursor || nextCursor === cursor || seenCursors.has(nextCursor)) {
1447
+ if (!continuationFound) throw this.invalidAddressHistoryCursorError(address, afterTxid!)
1448
+ throw this.nonAdvancingAddressHistoryCursorError(address, nextCursor ?? cursor)
1449
+ }
1450
+
1451
+ seenCursors.add(nextCursor)
1452
+ cursor = nextCursor
1453
+ cursorIsMempoolTx = !isConfirmedTx(txs[txs.length - 1]!)
1454
+ }
1455
+
1456
+ return confirmedTxs.slice(0, 25)
1457
+ }
1458
+
1459
+ /**
1460
+ * qbit-electrs's combined route answers an `after_txid` that is the last
1461
+ * mempool row with `[]`, not the confirmed rows that follow: it passes the
1462
+ * mempool txid on as the chain cursor, which `history()` never finds
1463
+ * (`src/rest.rs:992-998` in Qbit-Org/qbit-electrs@53102e9). Reject that
1464
+ * empty page when the address stats count confirmed transactions the scan
1465
+ * has not seen.
1466
+ */
1467
+ private async assertConfirmedHistoryReached(
1468
+ address: string,
1469
+ confirmedSeen: number,
1470
+ signal: AbortSignal
1471
+ ): Promise<void> {
1472
+ const stats = addressHistoryStats(
1473
+ await this.getJson<unknown>(`/address/${encodeURIComponent(address)}`, { signal }, false),
1474
+ address,
1475
+ 'mempool-qbit'
1476
+ )
1477
+ if (stats.chain_stats.tx_count > confirmedSeen) {
1478
+ throw new QbitApiError(
1479
+ 'invalid_cursor',
1480
+ `mempool-qbit combined history ended after a mempool transaction with ${confirmedSeen} of ${stats.chain_stats.tx_count} confirmed transactions: ${address}`
1481
+ )
1482
+ }
1483
+ }
1484
+
1485
+ /**
1486
+ * mempool-qbit does not expose `/txs/mempool`. Page through the combined
1487
+ * history until confirmed entries begin, collecting the leading mempool set.
1488
+ */
1489
+ override getAddressMempoolTxs(address: string, options?: RequestOptions): Promise<EsploraTx[]> {
1490
+ return this.runOperation(options, signal => this.scanAddressMempoolTxs(address, signal))
1491
+ }
1492
+
1493
+ private async scanAddressMempoolTxs(address: string, signal: AbortSignal): Promise<EsploraTx[]> {
1494
+ const mempoolTxs: EsploraTx[] = []
1495
+ let cursor: string | undefined
1496
+ const seenCursors = new Set<string>()
1497
+ const seenTxids = new Set<string>()
1498
+ let pageCount = 0
1499
+
1500
+ while (true) {
1501
+ if (pageCount >= this.maxAddressHistoryPages) {
1502
+ throw this.addressHistoryPaginationLimitError(address)
1503
+ }
1504
+ pageCount += 1
1505
+ const suffix = cursor ? `?after_txid=${encodeURIComponent(cursor)}` : ''
1506
+ const txs = await this.getJson<EsploraTx[]>(
1507
+ `/address/${encodeURIComponent(address)}/txs${suffix}`,
1508
+ { signal }
1509
+ )
1510
+ if (txs.length === 0) break
1511
+
1512
+ for (const tx of txs) {
1513
+ if (!isConfirmedTx(tx) && !seenTxids.has(tx.txid)) {
1514
+ mempoolTxs.push(tx)
1515
+ seenTxids.add(tx.txid)
1516
+ }
1517
+ }
1518
+
1519
+ const nextCursor = txs[txs.length - 1]?.txid
1520
+ if (txs.some(isConfirmedTx) || txs.length < MEMPOOL_QBIT_ADDRESS_PAGE_SIZE) break
1521
+ if (!nextCursor || nextCursor === cursor || seenCursors.has(nextCursor)) {
1522
+ throw this.nonAdvancingAddressHistoryCursorError(address, nextCursor ?? cursor)
1523
+ }
1524
+
1525
+ seenCursors.add(nextCursor)
1526
+ cursor = nextCursor
1527
+ }
1528
+
1529
+ return mempoolTxs
1530
+ }
1531
+
1532
+ getMempoolRecommendedFees(options?: RequestOptions): Promise<MempoolRecommendedFees> {
1533
+ return this.getJson<MempoolRecommendedFees>('/fees/recommended', options)
1534
+ }
1535
+
1536
+ override async getFeeEstimates(options?: RequestOptions): Promise<EsploraFeeEstimates> {
1537
+ return feeEstimatesFromMempoolRecommendedFees(await this.getMempoolRecommendedFees(options))
1538
+ }
1539
+
1540
+ /**
1541
+ * Broadcast via `/tx`, falling back to the `/tx/push` form route only when
1542
+ * `/tx` reported that it did not submit the transaction. The fallback is a
1543
+ * second submission, so it follows the same policy: an already-known reply
1544
+ * resolves with the txid and an uncertain attempt rejects with
1545
+ * `unknown_outcome`. An uncertain `/tx` attempt never falls back.
1546
+ *
1547
+ * One signal and deadline cover both requests, with the same abort rules as
1548
+ * `EsploraClient.broadcastTx`. An abort or expired deadline during `/tx`
1549
+ * never sends the `/tx/push` fallback.
1550
+ */
1551
+ override async broadcastTx(rawTxHex: string, options?: BroadcastTxOptions): Promise<string> {
1552
+ const txid = resolveBroadcastTxid(rawTxHex, options)
1553
+ return this.runOperation(options, async signal => {
1554
+ try {
1555
+ return await this.submitTx('/tx', rawTxHex, txid, { signal })
1556
+ } catch (error) {
1557
+ if (!shouldRetryBroadcastViaTxPush(error)) {
1558
+ throw error
1559
+ }
1560
+ // The backend's form route parses the literal `txHash=<hex>` text itself.
1561
+ return this.submitTx('/tx/push', `txHash=${rawTxHex}`, txid, { signal })
1562
+ }
1563
+ })
1564
+ }
1565
+
1566
+ private addressHistoryPaginationLimitError(
1567
+ address: string,
1568
+ maxPages = this.maxAddressHistoryPages
1569
+ ): QbitApiError {
1570
+ return new QbitApiError(
1571
+ 'pagination_limit',
1572
+ `mempool-qbit address history exceeded ${maxPages} pages: ${address}`
1573
+ )
1574
+ }
1575
+
1576
+ private invalidAddressHistoryCursorError(address: string, afterTxid: string): QbitApiError {
1577
+ return new QbitApiError(
1578
+ 'invalid_cursor',
1579
+ `mempool-qbit address history cursor is stale or not confirmed: ${afterTxid} (${address})`
1580
+ )
1581
+ }
1582
+
1583
+ private nonAdvancingAddressHistoryCursorError(address: string, cursor?: string): QbitApiError {
1584
+ return new QbitApiError(
1585
+ 'invalid_cursor',
1586
+ `mempool-qbit address history cursor stopped advancing: ${quotedResponseText(String(cursor ?? '(missing)'), this.baseUrl)} (${address})`
1587
+ )
1588
+ }
1589
+ }
1590
+
1591
+ // ---------------------------------------------------------------------------
1592
+ // Adapters & helpers
1593
+ // ---------------------------------------------------------------------------
1594
+
1595
+ export interface AddressBalance {
1596
+ address: string
1597
+ confirmedSats: number
1598
+ mempoolSats: number
1599
+ totalSats: number
1600
+ }
1601
+
1602
+ /**
1603
+ * Confirmed, mempool and total balance in satoshis from esplora address stats.
1604
+ *
1605
+ * Throws a `parse` {@link QbitApiError} unless `stats` has a string `address`
1606
+ * and `chain_stats` / `mempool_stats` objects whose `funded_txo_sum` and
1607
+ * `spent_txo_sum` are non-negative safe integers, the confirmed balance is not
1608
+ * negative, and `totalSats` is a non-negative safe integer. `mempoolSats` may
1609
+ * be negative while mempool transactions spend confirmed outputs.
1610
+ *
1611
+ * `funded_txo_sum` and `spent_txo_sum` are lifetime totals of everything the
1612
+ * address has received and spent, not balances, and `MAX_MONEY` does not
1613
+ * bound them. `JSON.parse` cannot represent integers above
1614
+ * `Number.MAX_SAFE_INTEGER` (2^53 - 1 sats, about 90M QBT) exactly, so it
1615
+ * rounds them, and they fail closed rather than yield a plausible but wrong
1616
+ * balance. A reused address that has received more than that over its life
1617
+ * therefore fails on every call, however little it holds now. For such an
1618
+ * address, sum the `value` of each output from
1619
+ * {@link EsploraClient.getAddressUtxos} instead; that raw endpoint is not
1620
+ * validated, so check that each value is a non-negative safe integer.
1621
+ *
1622
+ * The `parse` error for a negative total, here and from
1623
+ * {@link EsploraClient.getAddressBalance}, can be transient right after a
1624
+ * block on electrs: it applies the block's headers before indexing it and
1625
+ * does not deduplicate mempool stats against the chain, so a spend that was
1626
+ * just confirmed can count in both `chain_stats` and `mempool_stats` for a
1627
+ * moment. Retry it on the next poll.
1628
+ */
1629
+ export function addressBalanceFromStats(stats: EsploraAddressStats): AddressBalance {
1630
+ if (
1631
+ !isRecord(stats) ||
1632
+ typeof stats.address !== 'string' ||
1633
+ !hasSafeTxoSums(stats.chain_stats) ||
1634
+ !hasSafeTxoSums(stats.mempool_stats)
1635
+ ) {
1636
+ throw new QbitApiError('parse', 'address stats lacked a string address or non-negative safe-integer funded/spent sums')
1637
+ }
1638
+ const confirmedSats = stats.chain_stats.funded_txo_sum - stats.chain_stats.spent_txo_sum
1639
+ const mempoolSats = stats.mempool_stats.funded_txo_sum - stats.mempool_stats.spent_txo_sum
1640
+ const totalSats = confirmedSats + mempoolSats
1641
+ if (confirmedSats < 0 || !isSafeSatAmount(totalSats)) {
1642
+ throw new QbitApiError('parse', 'address stats implied a negative or unsafe balance')
1643
+ }
1644
+ return {
1645
+ address: stats.address,
1646
+ confirmedSats,
1647
+ mempoolSats,
1648
+ totalSats,
1649
+ }
1650
+ }
1651
+
1652
+ /**
1653
+ * Default `maxFeeRate` for {@link feeTiersFromEstimates}, in sat/vB. qbit Core's
1654
+ * `sendrawtransaction` refuses fee rates above `DEFAULT_MAX_RAW_TX_FEE_RATE{COIN / 10}`
1655
+ * per kvB by default (`src/node/transaction.h:27`), which is 10,000 sat/vB.
1656
+ */
1657
+ export const DEFAULT_MAX_FEE_RATE_SAT_VB = 10_000
1658
+
1659
+ /** Core's longest fee-estimate horizon: 42 periods of 24 blocks (`src/policy/fees.h:158-159`). */
1660
+ const MAX_FEE_ESTIMATE_TARGET = 1008
1661
+
1662
+ function isPositiveFeeRate(value: unknown): value is number {
1663
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
1664
+ }
1665
+
1666
+ /**
1667
+ * Only non-negative safe integers are exact satoshi amounts after `JSON.parse`.
1668
+ * Core's `MAX_MONEY` (`src/consensus/amount.h:26`) is 2.1e16, above 2^53, and
1669
+ * lifetime `funded_txo_sum` / `spent_txo_sum` totals are not bounded even by
1670
+ * that, so larger values are valid but cannot be represented and fail closed.
1671
+ */
1672
+ function isSafeSatAmount(value: unknown): value is number {
1673
+ return Number.isSafeInteger(value) && (value as number) >= 0
1674
+ }
1675
+
1676
+ function hasSafeTxoSums(value: unknown): boolean {
1677
+ return (
1678
+ isRecord(value) &&
1679
+ isSafeSatAmount(value['funded_txo_sum']) &&
1680
+ isSafeSatAmount(value['spent_txo_sum'])
1681
+ )
1682
+ }
1683
+
1684
+ export interface FeeRateTier {
1685
+ /** Confirmation target in blocks. */
1686
+ target: number
1687
+ /** Fee rate in sat/vB. */
1688
+ feeRate: number
1689
+ }
1690
+
1691
+ /**
1692
+ * Map mempool-qbit's projected blocks, leaving named policy floors unlabelled.
1693
+ *
1694
+ * Throws a `parse` {@link QbitApiError} unless `fees` is an object whose
1695
+ * `fastestFee`, `halfHourFee` and `hourFee` are finite numbers above 0. The
1696
+ * fee-rate ceiling is applied downstream by {@link feeTiersFromEstimates}.
1697
+ */
1698
+ export function feeEstimatesFromMempoolRecommendedFees(fees: MempoolRecommendedFees): EsploraFeeEstimates {
1699
+ if (!isRecord(fees)) {
1700
+ throw new QbitApiError('parse', 'mempool-qbit recommended fees were not a JSON object')
1701
+ }
1702
+ for (const field of ['fastestFee', 'halfHourFee', 'hourFee'] as const) {
1703
+ if (!isPositiveFeeRate(fees[field])) {
1704
+ throw new QbitApiError('parse', `mempool-qbit recommended fees had an invalid ${field}`)
1705
+ }
1706
+ }
1707
+ return {
1708
+ '1': fees.fastestFee,
1709
+ '2': fees.halfHourFee,
1710
+ '3': fees.hourFee,
1711
+ }
1712
+ }
1713
+
1714
+ /**
1715
+ * Fee-rate tiers (sat/vB) from an esplora fee-estimate map, ascending by
1716
+ * confirmation target. `{}` yields `[]`.
1717
+ *
1718
+ * Fails closed with a `parse` {@link QbitApiError} unless `estimates` is an
1719
+ * object whose every key is a canonical decimal target from 1 to 1008 blocks
1720
+ * and whose every value is a finite fee rate above 0 and at most `maxFeeRate`
1721
+ * (default {@link DEFAULT_MAX_FEE_RATE_SAT_VB}). The default is a ceiling, not
1722
+ * a sanity check: it only rejects rates above Core's default broadcast limit,
1723
+ * so a hostile or broken backend can still return 9,999 sat/vB. Set a lower
1724
+ * ceiling with `feeTiersFromEstimates(await client.getFeeEstimates(), { maxFeeRate })`,
1725
+ * cap the total fee before signing, or both.
1726
+ * Previously, malformed entries were silently dropped and no ceiling applied.
1727
+ *
1728
+ * @throws TypeError when `options.maxFeeRate` is not a finite number above 0.
1729
+ */
1730
+ export function feeTiersFromEstimates(
1731
+ estimates: EsploraFeeEstimates,
1732
+ options: { maxFeeRate?: number } = {}
1733
+ ): FeeRateTier[] {
1734
+ const maxFeeRate = options.maxFeeRate ?? DEFAULT_MAX_FEE_RATE_SAT_VB
1735
+ if (!isPositiveFeeRate(maxFeeRate)) {
1736
+ throw new TypeError('maxFeeRate must be a finite number above 0')
1737
+ }
1738
+ if (!isRecord(estimates)) {
1739
+ throw new QbitApiError('parse', 'fee estimates were not a JSON object')
1740
+ }
1741
+ return Object.entries(estimates)
1742
+ .map(([key, feeRate]) => {
1743
+ const target = Number(key)
1744
+ if (!/^[1-9]\d*$/.test(key) || target > MAX_FEE_ESTIMATE_TARGET) {
1745
+ throw new QbitApiError(
1746
+ 'parse',
1747
+ `fee estimates had a confirmation target that is not a decimal block count from 1 to ${MAX_FEE_ESTIMATE_TARGET}`
1748
+ )
1749
+ }
1750
+ if (!isPositiveFeeRate(feeRate) || feeRate > maxFeeRate) {
1751
+ throw new QbitApiError(
1752
+ 'parse',
1753
+ `fee estimate for target ${target} was not a finite rate above 0 and at most ${maxFeeRate} sat/vB`
1754
+ )
1755
+ }
1756
+ return { target, feeRate }
1757
+ })
1758
+ .sort((a, b) => a.target - b.target)
1759
+ }
1760
+
1761
+ /**
1762
+ * Transaction id of a raw transaction hex in legacy or BIP144 (witness)
1763
+ * encoding: the double SHA-256 of the witness-stripped serialization,
1764
+ * displayed byte-reversed. Throws a `parse` `QbitApiError` when the hex is not
1765
+ * a structurally complete transaction, or uses an encoding qbit Core refuses
1766
+ * to decode even where the txid would not change: a count or length (witness
1767
+ * section included) in a longer CompactSize form than its value needs or above
1768
+ * 0x02000000 (Core's MAX_SIZE), or a witness flag with every witness stack
1769
+ * empty.
1770
+ */
1771
+ export function txidFromRawTx(rawTxHex: string): string {
1772
+ const txid = tryTxidFromRawTx(rawTxHex)
1773
+ if (txid === undefined) {
1774
+ throw new QbitApiError('parse', 'raw transaction hex is not a well-formed transaction')
1775
+ }
1776
+ return txid
1777
+ }
1778
+
1779
+ /**
1780
+ * `text` without the spaces, tabs, CRs and LFs around it. An index scan, since
1781
+ * a regex trim backtracks quadratically over a long whitespace run inside the
1782
+ * text, and a reply body is unbounded. Other whitespace (NBSP, ...) is kept.
1783
+ */
1784
+ function trimAsciiWhitespace(text: string): string {
1785
+ let start = 0
1786
+ let end = text.length
1787
+ while (start < end && isAsciiWhitespace(text.charCodeAt(start))) start += 1
1788
+ while (end > start && isAsciiWhitespace(text.charCodeAt(end - 1))) end -= 1
1789
+ return text.slice(start, end)
1790
+ }
1791
+
1792
+ function isAsciiWhitespace(code: number): boolean {
1793
+ return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d
1794
+ }
1795
+
1796
+ function tryTxidFromRawTx(rawTxHex: string): string | undefined {
1797
+ const bytes = hexToBytes(rawTxHex)
1798
+ const stripped = bytes === undefined ? undefined : witnessStrippedTx(bytes)
1799
+ if (stripped === undefined) return undefined
1800
+ return bytesToHex(sha256(sha256(stripped)).reverse())
1801
+ }
1802
+
1803
+ /**
1804
+ * The txid a submission is tracked by: `options.txid` when given (it must be a
1805
+ * 64-hex id and agree with the hex), else the id derived from the hex, else
1806
+ * undefined when the hex is not a parseable transaction. Argument errors are
1807
+ * `TypeError`s thrown before any request is made.
1808
+ */
1809
+ function resolveBroadcastTxid(
1810
+ rawTxHex: string,
1811
+ options: BroadcastTxOptions | null | undefined
1812
+ ): string | undefined {
1813
+ if (typeof rawTxHex !== 'string') {
1814
+ throw new TypeError('broadcastTx: rawTxHex must be a hex string')
1815
+ }
1816
+ const requested = (options ?? {}).txid
1817
+ const derived = tryTxidFromRawTx(rawTxHex)
1818
+ if (requested === undefined) return derived
1819
+ const provided = typeof requested === 'string' ? requested.toLowerCase() : ''
1820
+ if (!TXID_PATTERN.test(provided)) {
1821
+ throw new TypeError('broadcastTx: options.txid must be a 64-character hex transaction id')
1822
+ }
1823
+ if (derived !== undefined && derived !== provided) {
1824
+ throw new TypeError(
1825
+ `broadcastTx: options.txid ${provided} does not match the transaction hex (${derived})`
1826
+ )
1827
+ }
1828
+ return provided
1829
+ }
1830
+
1831
+ /** qbit Core `serialize.h` MAX_SIZE: the largest CompactSize `ReadCompactSize` accepts. */
1832
+ const COMPACT_SIZE_MAX = 0x02000000
1833
+
1834
+ class RawTxReader {
1835
+ private offset = 0
1836
+
1837
+ constructor(private readonly bytes: Uint8Array) {}
1838
+
1839
+ get position(): number {
1840
+ return this.offset
1841
+ }
1842
+
1843
+ get remaining(): number {
1844
+ return this.bytes.length - this.offset
1845
+ }
1846
+
1847
+ skip(length: number): void {
1848
+ if (length > this.remaining) throw new RangeError('raw transaction is truncated')
1849
+ this.offset += length
1850
+ }
1851
+
1852
+ readUint8(): number {
1853
+ this.skip(1)
1854
+ return this.bytes[this.offset - 1]!
1855
+ }
1856
+
1857
+ /**
1858
+ * qbit Core `ReadCompactSize` with its default range check (serialize.h:330
1859
+ * at v1.0.0). Only the shortest encoding of a value is valid: a `fd` prefix
1860
+ * must carry at least 0xfd, `fe` at least 0x10000 and `ff` at least
1861
+ * 0x100000000, or Core throws `non-canonical ReadCompactSize()`. A value
1862
+ * above MAX_SIZE (serialize.h:32) fails `size too large`, so every `ff`
1863
+ * encoding fails one check or the other. Core reads every count and length
1864
+ * in a transaction this way, witness stacks included.
1865
+ */
1866
+ readCompactSize(): number {
1867
+ const first = this.readUint8()
1868
+ if (first < 0xfd) return first
1869
+ const width = first === 0xfd ? 2 : first === 0xfe ? 4 : 8
1870
+ let value = 0
1871
+ for (let i = 0; i < width; i += 1) value += this.readUint8() * 2 ** (8 * i)
1872
+ const minimum = first === 0xfd ? 0xfd : first === 0xfe ? 0x10000 : 2 ** 32
1873
+ if (value < minimum) throw new RangeError('raw transaction has a non-canonical CompactSize')
1874
+ if (value > COMPACT_SIZE_MAX) throw new RangeError('raw transaction length is too large')
1875
+ return value
1876
+ }
1877
+
1878
+ skipVarBytes(): void {
1879
+ this.skip(this.readCompactSize())
1880
+ }
1881
+ }
1882
+
1883
+ /**
1884
+ * The witness-stripped encoding of a complete transaction, or undefined when
1885
+ * the bytes are truncated, carry trailing data, use an unknown marker, encode
1886
+ * a count or length non-canonically or above MAX_SIZE, or set the witness flag
1887
+ * with every witness stack empty. qbit Core refuses the last two even though
1888
+ * the witness section does not change the txid (`non-canonical
1889
+ * ReadCompactSize()` / `size too large`, serialize.h:330-359, and
1890
+ * `Superfluous witness record`, primitives/transaction.h:237-246, at v1.0.0;
1891
+ * a stack holding one empty item is not empty, per `ComputeHasWitness`,
1892
+ * primitives/transaction.cpp:74). Structure only: script contents and counts
1893
+ * are not validated further, so any transaction a node could decode yields
1894
+ * its txid.
1895
+ */
1896
+ function witnessStrippedTx(bytes: Uint8Array): Uint8Array | undefined {
1897
+ const reader = new RawTxReader(bytes)
1898
+ try {
1899
+ reader.skip(4) // version
1900
+ const witness = reader.remaining >= 2 && bytes[4] === 0x00
1901
+ if (witness) {
1902
+ if (bytes[5] !== 0x01) return undefined
1903
+ reader.skip(2) // marker + flag
1904
+ }
1905
+ const bodyStart = reader.position
1906
+ const inputCount = reader.readCompactSize()
1907
+ for (let i = 0; i < inputCount; i += 1) {
1908
+ reader.skip(36) // outpoint
1909
+ reader.skipVarBytes() // scriptSig
1910
+ reader.skip(4) // sequence
1911
+ }
1912
+ const outputCount = reader.readCompactSize()
1913
+ for (let i = 0; i < outputCount; i += 1) {
1914
+ reader.skip(8) // value
1915
+ reader.skipVarBytes() // scriptPubKey
1916
+ }
1917
+ const bodyEnd = reader.position
1918
+ if (witness) {
1919
+ let hasWitness = false
1920
+ for (let i = 0; i < inputCount; i += 1) {
1921
+ const items = reader.readCompactSize()
1922
+ if (items > 0) hasWitness = true
1923
+ for (let j = 0; j < items; j += 1) reader.skipVarBytes()
1924
+ }
1925
+ if (!hasWitness) return undefined
1926
+ }
1927
+ reader.skip(4) // locktime
1928
+ if (reader.remaining !== 0) return undefined
1929
+ if (!witness) return bytes
1930
+ const stripped = new Uint8Array(4 + (bodyEnd - bodyStart) + 4)
1931
+ stripped.set(bytes.subarray(0, 4), 0)
1932
+ stripped.set(bytes.subarray(bodyStart, bodyEnd), 4)
1933
+ stripped.set(bytes.subarray(bytes.length - 4), stripped.length - 4)
1934
+ return stripped
1935
+ } catch {
1936
+ return undefined
1937
+ }
1938
+ }
1939
+
1940
+ function hexToBytes(hex: string): Uint8Array | undefined {
1941
+ if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) return undefined
1942
+ const bytes = new Uint8Array(hex.length / 2)
1943
+ for (let i = 0; i < bytes.length; i += 1) {
1944
+ bytes[i] = parseInt(hex.slice(2 * i, 2 * i + 2), 16)
1945
+ }
1946
+ return bytes
1947
+ }
1948
+
1949
+ function bytesToHex(bytes: Uint8Array): string {
1950
+ let hex = ''
1951
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, '0')
1952
+ return hex
1953
+ }
1954
+
1955
+ type AddressHistoryBackend = 'esplora' | 'mempool-qbit'
1956
+ type AddressHistorySource = 'confirmed' | 'mempool' | 'combined'
1957
+
1958
+ interface AddressHistoryScanState extends AddressHistory {
1959
+ seenTxids: Set<string>
1960
+ }
1961
+
1962
+ function createAddressHistoryScanState(): AddressHistoryScanState {
1963
+ return {
1964
+ confirmed: [],
1965
+ mempool: [],
1966
+ seenTxids: new Set<string>(),
1967
+ }
1968
+ }
1969
+
1970
+ /** Page fetches and raw rows one `getAddressHistory` call has used, across its scans. */
1971
+ interface AddressHistoryBudget {
1972
+ pageRequests: number
1973
+ rawTransactions: number
1974
+ }
1975
+
1976
+ function createAddressHistoryBudget(): AddressHistoryBudget {
1977
+ return { pageRequests: 0, rawTransactions: 0 }
1978
+ }
1979
+
1980
+ /** Scans repeated because the address stats changed during the previous one. */
1981
+ const MAX_ADDRESS_HISTORY_RESCANS = 1
1982
+
1983
+ type AddressHistoryStats = Pick<EsploraAddressStats, 'chain_stats' | 'mempool_stats'>
1984
+
1985
+ const ADDRESS_STATS_FIELDS = [
1986
+ 'funded_txo_count',
1987
+ 'funded_txo_sum',
1988
+ 'spent_txo_count',
1989
+ 'spent_txo_sum',
1990
+ 'tx_count',
1991
+ ] as const
1992
+
1993
+ /**
1994
+ * Run `scan` between two reads of the address stats and return its history
1995
+ * only if the reads agree and it holds exactly `chain_stats.tx_count`
1996
+ * confirmed and `mempool_stats.tx_count` mempool transactions. electrs
1997
+ * returns HTTP 200 and `[]` for a cursor it cannot find (`skip_while` in
1998
+ * `ChainQuery::_history`, `src/new_index/schema.rs:1031-1060` in
1999
+ * Qbit-Org/qbit-electrs@53102e9), and both counts are of distinct txids, as
2000
+ * the history routes list them (`stats_delta`, `schema.rs:1277-1305`;
2001
+ * `Mempool::stats`, `src/new_index/mempool.rs:280-292`). A scan during which
2002
+ * the stats changed is repeated, starting from the later read.
2003
+ */
2004
+ async function verifiedAddressHistory(
2005
+ address: string,
2006
+ backend: AddressHistoryBackend,
2007
+ readStats: () => Promise<unknown>,
2008
+ scan: () => Promise<AddressHistory>
2009
+ ): Promise<AddressHistory> {
2010
+ let before = addressHistoryStats(await readStats(), address, backend)
2011
+ for (let scans = 1; ; scans += 1) {
2012
+ const history = await scan()
2013
+ const after = addressHistoryStats(await readStats(), address, backend)
2014
+ if (!sameAddressHistoryStats(before, after)) {
2015
+ if (scans > MAX_ADDRESS_HISTORY_RESCANS) {
2016
+ throw new QbitApiError(
2017
+ 'invalid_cursor',
2018
+ `${backend} address stats changed during each of ${scans} history scans: ${address}`
2019
+ )
2020
+ }
2021
+ before = after
2022
+ continue
2023
+ }
2024
+ const confirmed = after.chain_stats.tx_count
2025
+ const mempool = after.mempool_stats.tx_count
2026
+ if (history.confirmed.length !== confirmed || history.mempool.length !== mempool) {
2027
+ throw new QbitApiError(
2028
+ 'invalid_cursor',
2029
+ `${backend} address history returned ${history.confirmed.length} confirmed and ${history.mempool.length} mempool transactions, but the address stats count ${confirmed} and ${mempool}: ${address}`
2030
+ )
2031
+ }
2032
+ return history
2033
+ }
2034
+ }
2035
+
2036
+ function addressHistoryStats(
2037
+ value: unknown,
2038
+ address: string,
2039
+ backend: AddressHistoryBackend
2040
+ ): AddressHistoryStats {
2041
+ if (!isRecord(value) || !hasTxCount(value['chain_stats']) || !hasTxCount(value['mempool_stats'])) {
2042
+ throw new QbitApiError(
2043
+ 'parse',
2044
+ `${backend} address stats lacked a non-negative safe-integer chain or mempool tx_count: ${address}`
2045
+ )
2046
+ }
2047
+ return value as unknown as AddressHistoryStats
2048
+ }
2049
+
2050
+ function hasTxCount(value: unknown): boolean {
2051
+ return isRecord(value) && Number.isSafeInteger(value['tx_count']) && (value['tx_count'] as number) >= 0
2052
+ }
2053
+
2054
+ function sameAddressHistoryStats(a: AddressHistoryStats, b: AddressHistoryStats): boolean {
2055
+ return ADDRESS_STATS_FIELDS.every(
2056
+ field =>
2057
+ a.chain_stats[field] === b.chain_stats[field] &&
2058
+ a.mempool_stats[field] === b.mempool_stats[field]
2059
+ )
2060
+ }
2061
+
2062
+ function positiveAddressHistoryLimit(
2063
+ value: number | undefined,
2064
+ defaultValue: number,
2065
+ name: keyof AddressHistoryOptions
2066
+ ): number {
2067
+ const limit = value ?? defaultValue
2068
+ if (!Number.isSafeInteger(limit) || limit < 1) {
2069
+ throw new QbitApiError('http', `${name} must be a positive integer`)
2070
+ }
2071
+ return limit
2072
+ }
2073
+
2074
+ function assertAddressHistoryPage(
2075
+ value: unknown[],
2076
+ address: string,
2077
+ source: string
2078
+ ): EsploraTx[] {
2079
+ value.forEach((tx, index) => {
2080
+ const status = isRecord(tx) ? tx['status'] : undefined
2081
+ const vin = isRecord(tx) ? tx['vin'] : undefined
2082
+ const vout = isRecord(tx) ? tx['vout'] : undefined
2083
+ if (
2084
+ !isRecord(tx) ||
2085
+ typeof tx['txid'] !== 'string' ||
2086
+ !/^[0-9a-f]{64}$/i.test(tx['txid']) ||
2087
+ !isRecord(status) ||
2088
+ typeof status['confirmed'] !== 'boolean' ||
2089
+ !Array.isArray(vin) ||
2090
+ !vin.every(isRecord) ||
2091
+ !Array.isArray(vout) ||
2092
+ !vout.every(isRecord)
2093
+ ) {
2094
+ throw new QbitApiError(
2095
+ 'parse',
2096
+ `${source} address history transaction ${index} had an invalid txid, status, vin, or vout: ${address}`
2097
+ )
2098
+ }
2099
+ const spendsFromAddress = vin.some(
2100
+ input =>
2101
+ isRecord(input) &&
2102
+ isRecord(input['prevout']) &&
2103
+ addressHistoryAddressMatches(input['prevout']['scriptpubkey_address'], address)
2104
+ )
2105
+ const paysToAddress = vout.some(
2106
+ output =>
2107
+ isRecord(output) && addressHistoryAddressMatches(output['scriptpubkey_address'], address)
2108
+ )
2109
+ if (!spendsFromAddress && !paysToAddress) {
2110
+ throw new QbitApiError(
2111
+ 'parse',
2112
+ `${source} address history transaction ${index} did not touch ${address}`
2113
+ )
2114
+ }
2115
+ })
2116
+ return value as EsploraTx[]
2117
+ }
2118
+
2119
+ function reserveAddressHistoryTransactions(
2120
+ budget: AddressHistoryBudget,
2121
+ value: unknown,
2122
+ maxTransactions: number,
2123
+ address: string,
2124
+ backend: AddressHistoryBackend,
2125
+ source: string
2126
+ ): unknown[] {
2127
+ if (!Array.isArray(value)) {
2128
+ throw new QbitApiError('parse', `${source} address history was not an array: ${address}`)
2129
+ }
2130
+ budget.rawTransactions += value.length
2131
+ if (budget.rawTransactions > maxTransactions) {
2132
+ throw new QbitApiError(
2133
+ 'pagination_limit',
2134
+ `${backend} address history exceeded ${maxTransactions} transactions: ${address}`
2135
+ )
2136
+ }
2137
+ return value
2138
+ }
2139
+
2140
+ function addressHistoryAddressMatches(value: unknown, address: string): boolean {
2141
+ if (typeof value !== 'string') return false
2142
+ if (value === address) return true
2143
+ if (!isUniformCaseQbitBech32(value) || !isUniformCaseQbitBech32(address)) return false
2144
+ return value.toLowerCase() === address.toLowerCase()
2145
+ }
2146
+
2147
+ const QBIT_BECH32_HRPS = new Set(['qb', 'tq', 'qbrt'])
2148
+
2149
+ function isUniformCaseQbitBech32(value: string): boolean {
2150
+ const normalized = value.toLowerCase()
2151
+ const separator = value.lastIndexOf('1')
2152
+ const hrp = normalized.slice(0, separator)
2153
+ const data = value.slice(separator + 1)
2154
+ const hasUniformCase = value === value.toLowerCase() || value === value.toUpperCase()
2155
+ return (
2156
+ separator > 0 &&
2157
+ QBIT_BECH32_HRPS.has(hrp) &&
2158
+ data.length >= 6 &&
2159
+ hasUniformCase &&
2160
+ /^[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/i.test(data)
2161
+ )
2162
+ }
2163
+
2164
+ function nextAddressHistoryCursor(
2165
+ txs: EsploraTx[],
2166
+ cursor: string | undefined,
2167
+ seenCursors: Set<string>,
2168
+ address: string,
2169
+ backend: AddressHistoryBackend
2170
+ ): string {
2171
+ const nextCursor = txs[txs.length - 1]?.txid
2172
+ if (!nextCursor) {
2173
+ throw new QbitApiError(
2174
+ 'invalid_cursor',
2175
+ `${backend} address history cursor stopped advancing: (missing) (${address})`
2176
+ )
2177
+ }
2178
+ const normalizedNextCursor = nextCursor.toLowerCase()
2179
+ if (normalizedNextCursor === cursor?.toLowerCase() || seenCursors.has(normalizedNextCursor)) {
2180
+ throw new QbitApiError(
2181
+ 'invalid_cursor',
2182
+ `${backend} address history cursor stopped advancing: ${nextCursor} (${address})`
2183
+ )
2184
+ }
2185
+ seenCursors.add(normalizedNextCursor)
2186
+ return nextCursor
2187
+ }
2188
+
2189
+ function appendAddressHistoryTransactions(
2190
+ state: AddressHistoryScanState,
2191
+ txs: EsploraTx[],
2192
+ source: AddressHistorySource,
2193
+ address: string,
2194
+ backend: AddressHistoryBackend
2195
+ ): void {
2196
+ for (const tx of txs) {
2197
+ const normalizedTxid = tx.txid.toLowerCase()
2198
+ if (state.seenTxids.has(normalizedTxid)) {
2199
+ throw new QbitApiError(
2200
+ 'invalid_cursor',
2201
+ `${backend} address history repeated transaction ${tx.txid}: ${address}`
2202
+ )
2203
+ }
2204
+ state.seenTxids.add(normalizedTxid)
2205
+
2206
+ const confirmed = isConfirmedTx(tx)
2207
+ if (source === 'confirmed' && !confirmed) {
2208
+ throw new QbitApiError(
2209
+ 'parse',
2210
+ `esplora confirmed history returned an unconfirmed transaction ${tx.txid}: ${address}`
2211
+ )
2212
+ }
2213
+ if (source === 'mempool' && confirmed) {
2214
+ throw new QbitApiError(
2215
+ 'parse',
2216
+ `esplora mempool history returned a confirmed transaction ${tx.txid}: ${address}`
2217
+ )
2218
+ }
2219
+
2220
+ if (confirmed) state.confirmed.push(tx)
2221
+ else state.mempool.push(tx)
2222
+ }
2223
+ }
2224
+
2225
+ function addressHistoryPageLimitError(
2226
+ address: string,
2227
+ maxPages: number,
2228
+ backend: AddressHistoryBackend
2229
+ ): QbitApiError {
2230
+ return new QbitApiError(
2231
+ 'pagination_limit',
2232
+ `${backend} address history exceeded ${maxPages} page fetches: ${address}`
2233
+ )
2234
+ }
2235
+
2236
+ function isConfirmedTx(tx: EsploraTx): boolean {
2237
+ return tx.status?.confirmed === true
2238
+ }
2239
+
2240
+ function unknownOutcomeError(
2241
+ url: string,
2242
+ txid: string | undefined,
2243
+ reason: string,
2244
+ options: Omit<QbitApiErrorOptions, 'url' | 'txid'> = {}
2245
+ ): QbitApiError {
2246
+ const subject = txid === undefined ? 'the transaction' : `txid ${txid}`
2247
+ return new QbitApiError(
2248
+ 'unknown_outcome',
2249
+ `transaction submission outcome unknown (${reason}); ${subject} may have been accepted, check its status before resubmitting`,
2250
+ { ...options, url, ...(txid === undefined ? {} : { txid }) }
2251
+ )
2252
+ }
2253
+
2254
+ interface ErrorCodeEntry {
2255
+ code: string
2256
+ syscall: string | undefined
2257
+ }
2258
+
2259
+ /**
2260
+ * True when every coded error in the chain says no connection was
2261
+ * established. `EHOSTUNREACH`/`ENETUNREACH` count only from the connect
2262
+ * syscall, since a route can also vanish after the request was written.
2263
+ */
2264
+ function isPreConnectionFailure(error: unknown): boolean {
2265
+ const entries = collectErrorCodes(error, 0, [])
2266
+ return (
2267
+ entries.length > 0 &&
2268
+ entries.every(
2269
+ entry =>
2270
+ PRE_CONNECTION_ERROR_CODES.has(entry.code) ||
2271
+ (CONNECT_STAGE_ERROR_CODES.has(entry.code) && entry.syscall === 'connect')
2272
+ )
2273
+ )
2274
+ }
2275
+
2276
+ function collectErrorCodes(error: unknown, depth: number, entries: ErrorCodeEntry[]): ErrorCodeEntry[] {
2277
+ if (!isRecord(error)) return entries
2278
+ // Too deep to judge: count it as a failure that may have reached the server,
2279
+ // so a cut-off member can't leave only pre-connection codes behind.
2280
+ if (depth > 5) {
2281
+ entries.push({ code: 'UNJUDGED', syscall: undefined })
2282
+ return entries
2283
+ }
2284
+ const nested = error['errors']
2285
+ if (Array.isArray(nested) && nested.length > 0) {
2286
+ // An AggregateError (happy eyeballs) summarises its members; judge each member.
2287
+ for (const entry of nested) collectErrorCodes(entry, depth + 1, entries)
2288
+ } else if (typeof error['code'] === 'string') {
2289
+ const syscall = error['syscall']
2290
+ entries.push({ code: error['code'], syscall: typeof syscall === 'string' ? syscall : undefined })
2291
+ }
2292
+ collectErrorCodes(error['cause'], depth + 1, entries)
2293
+ return entries
2294
+ }
2295
+
2296
+ /**
2297
+ * Node replies meaning this exact transaction is already in the mempool or
2298
+ * chain: qbit Core's `txn-already-in-mempool`, `txn-already-known`,
2299
+ * `txn-same-nonwitness-data-in-mempool`, and RPC error -27 (already in block
2300
+ * chain / outputs already in utxo set), as relayed by esplora and mempool.
2301
+ * Conflicts with a different transaction (`txn-mempool-conflict`,
2302
+ * `bad-txns-inputs-missingorspent`) are not matched.
2303
+ */
2304
+ const ALREADY_KNOWN_BROADCAST_REPLY =
2305
+ /\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/
2306
+
2307
+ function isAlreadyKnownBroadcastReply(reply: BroadcastReply): boolean {
2308
+ if (reply.code === -27) return true
2309
+ return ALREADY_KNOWN_BROADCAST_REPLY.test(normalizeReplyText(reply.message))
2310
+ }
2311
+
2312
+ /**
2313
+ * A reply in which the backend reports that its own call to the node failed
2314
+ * in transit or came back unreadable, so the node may still have processed
2315
+ * it. mempool-qbit serialises the transport error code (`{"code":"ETIMEDOUT"}`,
2316
+ * `ESOCKETTIMEDOUT`, `ECONNRESET`; backend/src/rpc-api/jsonrpc.ts via
2317
+ * bitcoin.routes.ts), `-32603` when the node answered 200 with an unreadable
2318
+ * body, or `-32602` when it answered non-200 with one: qbit Core answers 500
2319
+ * for every RPC error, so that reply may have been a -27 (already confirmed)
2320
+ * as well as a rejection or an auth failure, and `sendrawtransaction` itself
2321
+ * never raises -32602. qbit-electrs answers `Connection error` once it cannot
2322
+ * reconnect to the daemon (src/daemon.rs). Codes proving the backend never
2323
+ * connected (`ECONNREFUSED`, `ENOTFOUND`, `EAI_AGAIN`) stay definite failures.
2324
+ */
2325
+ function isRelayedTransportFailure(reply: BroadcastReply): boolean {
2326
+ if (typeof reply.code === 'string') return !PRE_CONNECTION_ERROR_CODES.has(reply.code)
2327
+ if (reply.code === -32603 || reply.code === -32602) return true
2328
+ return /^connection error\b/.test(normalizeReplyText(reply.message))
2329
+ }
2330
+
2331
+ /**
2332
+ * JSON-RPC request-level codes: the server refused the request before running
2333
+ * any method (qbit Core src/rpc/protocol.h). `-32602` is left out because a
2334
+ * relaying backend reuses it for an unreadable non-200 node reply; see
2335
+ * `isRelayedTransportFailure`.
2336
+ */
2337
+ const RPC_REQUEST_ERROR_CODES = new Set([-32700, -32600, -32601])
2338
+
2339
+ /**
2340
+ * True for an RPC error code that is the node's verdict on the request: one of
2341
+ * qbit Core's application codes (src/rpc/protocol.h defines -1 … -36; for
2342
+ * `sendrawtransaction` that is -22 `TX decode failed`, -25 verify error /
2343
+ * missing inputs, -26 rejected by network rules, -28 warming up, and the
2344
+ * parameter errors -1/-3/-8, all raised before the mempool is touched —
2345
+ * src/rpc/mempool.cpp, src/rpc/util.cpp `RPCErrorFromTransactionError`), or
2346
+ * a request-level code. `-32603` and `-32602` (mempool-qbit's markers for an
2347
+ * unreadable node reply) and the implementation-defined server-error range
2348
+ * `-32099 … -32000`, which Core never emits and proxies use for upstream
2349
+ * failures, describe the server or its transport, not the transaction.
2350
+ */
2351
+ function isNodeVerdictCode(code: number): boolean {
2352
+ return (code >= -99 && code <= -1) || RPC_REQUEST_ERROR_CODES.has(code)
2353
+ }
2354
+
2355
+ /**
2356
+ * qbit Core's literal transaction reject reasons (src/validation.cpp,
2357
+ * src/policy/policy.cpp, src/consensus/tx_check.cpp, src/rpc/mempool.cpp),
2358
+ * recognised only at the start of an uncoded reply. Reasons that are ordinary
2359
+ * words (`coinbase`, `version`) are left out so a gateway page cannot match.
2360
+ */
2361
+ const DEFINITE_REJECTION_REPLY =
2362
+ /^(?: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)/
2363
+
2364
+ /**
2365
+ * A reply in which the node itself refused or rejected this submission, so
2366
+ * the transaction was not accepted whatever the HTTP status says: a node
2367
+ * verdict code, a relayed code proving the backend never connected to the
2368
+ * node, or, when the body carries no code at all, one of Core's reject
2369
+ * reasons. Already-known replies (-27) are resolved before this is consulted.
2370
+ */
2371
+ function isDefiniteNodeRejection(reply: BroadcastReply): boolean {
2372
+ if (typeof reply.code === 'number') return isNodeVerdictCode(reply.code)
2373
+ if (typeof reply.code === 'string') return PRE_CONNECTION_ERROR_CODES.has(reply.code)
2374
+ return DEFINITE_REJECTION_REPLY.test(normalizeReplyText(reply.message))
2375
+ }
2376
+
2377
+ /**
2378
+ * Fallback only after `/tx` reported that it did not submit: a missing route
2379
+ * or the backend's generic no-RPC-code failure. `unknown_outcome` never
2380
+ * qualifies, since the transaction may already be with the node.
2381
+ */
2382
+ function shouldRetryBroadcastViaTxPush(error: unknown): boolean {
2383
+ if (!QbitApiError.is(error)) return false
2384
+ if (
2385
+ (error.kind === 'not_found' && error.status === 404) ||
2386
+ (error.kind === 'http' && (error.status === 405 || error.status === 501))
2387
+ ) {
2388
+ return true
2389
+ }
2390
+ // Another copy's error keeps its raw body where this copy cannot read it, and
2391
+ // its `responseBody` may be a redacted preview, so its body never qualifies.
2392
+ const body = error instanceof QbitApiError ? (rawResponseBodies.get(error) ?? error.responseBody) : undefined
2393
+ if (error.kind !== 'http' || error.status !== 400 || body === undefined) {
2394
+ return false
2395
+ }
2396
+
2397
+ // mempool-qbit reserves this generic response for failures without an RPC
2398
+ // error code; a reply carrying a node RPC or transport code is never that
2399
+ // generic failure, whatever its message says.
2400
+ const reply = parseBroadcastReply(body)
2401
+ if (reply.code !== undefined) return false
2402
+ return /^(?:(?:failed|unable) to|could not) (?:send|submit|broadcast)(?: raw)? transaction$/.test(
2403
+ normalizeReplyText(reply.message)
2404
+ )
2405
+ }
2406
+
2407
+ interface BroadcastReply {
2408
+ /**
2409
+ * Node RPC error code (a negative integer, as all of qbit Core's are) or
2410
+ * relayed transport code (upper-cased, see `TRANSPORT_ERROR_CODE_PATTERN`)
2411
+ * from a structured body. A positive number is an HTTP status echo and
2412
+ * any other string a gateway code; neither describes the node call, so
2413
+ * both are left out.
2414
+ */
2415
+ code: number | string | undefined
2416
+ /** Human-readable detail: the structured body's message, or the body itself. */
2417
+ message: string
2418
+ }
2419
+
2420
+ /**
2421
+ * Read a rejection body into its error code and message. Backends answer in
2422
+ * several shapes: plain text, optionally with an embedded
2423
+ * `sendrawtransaction RPC error: {"code":...,"message":...}` object (esplora,
2424
+ * mempool-qbit); the node's JSON-RPC envelope `{"error":{"code","message"}}`;
2425
+ * a flat `{"code","message"}` object; or a JSON string / `{"error":"..."}` /
2426
+ * `{"message":"..."}` wrapper around the plain-text form. The code is taken
2427
+ * from the structured object before the body is reduced to its message, since
2428
+ * the code is authoritative while the message may be localised or reworded.
2429
+ * A nested `error.code` wins over a top-level `code`.
2430
+ */
2431
+ function parseBroadcastReply(body: string): BroadcastReply {
2432
+ let text = body
2433
+ try {
2434
+ const parsed = JSON.parse(body) as unknown
2435
+ if (typeof parsed === 'string') {
2436
+ text = parsed
2437
+ } else if (isRecord(parsed)) {
2438
+ const nested = parsed['error']
2439
+ const code = errorCodeOf(nested) ?? errorCodeOf(parsed)
2440
+ let message = body
2441
+ if (typeof nested === 'string') message = nested
2442
+ else if (isRecord(nested) && typeof nested['message'] === 'string') message = nested['message']
2443
+ else if (typeof parsed['message'] === 'string') message = parsed['message']
2444
+ // A JSON wrapper around the plain-text form still carries the code in that text.
2445
+ return { code: code ?? embeddedErrorCode(message), message }
2446
+ }
2447
+ } catch {
2448
+ // Plain-text backend errors are expected here.
2449
+ }
2450
+ return { code: embeddedErrorCode(text), message: text }
2451
+ }
2452
+
2453
+ function errorCodeOf(value: unknown): number | string | undefined {
2454
+ if (!isRecord(value)) return undefined
2455
+ const code = value['code']
2456
+ if (typeof code === 'number') return Number.isInteger(code) && code < 0 ? code : undefined
2457
+ if (typeof code !== 'string') return undefined
2458
+ const transportCode = code.toUpperCase()
2459
+ return TRANSPORT_ERROR_CODE_PATTERN.test(transportCode) ? transportCode : undefined
2460
+ }
2461
+
2462
+ /**
2463
+ * Code from an esplora/mempool `sendrawtransaction RPC error: {"code":...}`
2464
+ * object embedded in plain text: a number for node RPC errors, or a string
2465
+ * when mempool-qbit relays its own transport failure (`{"code":"ETIMEDOUT"}`).
2466
+ */
2467
+ function embeddedErrorCode(text: string): number | string | undefined {
2468
+ const start = text.indexOf('{')
2469
+ const end = text.lastIndexOf('}')
2470
+ if (start === -1 || end <= start) return undefined
2471
+ try {
2472
+ const parsed = JSON.parse(text.slice(start, end + 1)) as unknown
2473
+ if (isRecord(parsed)) return errorCodeOf(parsed['error']) ?? errorCodeOf(parsed)
2474
+ } catch {
2475
+ // Not an RPC error object; the caller falls back to message matching.
2476
+ }
2477
+ return undefined
2478
+ }
2479
+
2480
+ function normalizeReplyText(text: string): string {
2481
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
2482
+ }
2483
+
2484
+ function isRecord(value: unknown): value is Record<string, unknown> {
2485
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
2486
+ }
2487
+
2488
+ /** `Retry-After` (delta-seconds or HTTP-date) as a non-negative, finite whole number of ms. */
2489
+ function parseRetryAfter(value: string | null): number | undefined {
2490
+ if (!value) return undefined
2491
+ let ms: number
2492
+ const seconds = Number(value)
2493
+ // Overlong delta-seconds overflow to Infinity; keep them as "too long", not unparseable.
2494
+ if (!Number.isNaN(seconds)) {
2495
+ ms = seconds * 1000
2496
+ } else {
2497
+ const date = Date.parse(value)
2498
+ if (!Number.isFinite(date)) return undefined
2499
+ ms = date - Date.now()
2500
+ }
2501
+ return Math.min(Math.max(0, Math.ceil(ms)), Number.MAX_SAFE_INTEGER)
2502
+ }
2503
+
2504
+ function isAbortError(error: unknown): boolean {
2505
+ return typeof error === 'object' && error !== null && (error as { name?: string }).name === 'AbortError'
2506
+ }
2507
+
2508
+ /** `localhost`, `127.0.0.0/8` or `[::1]`, as normalized by the URL parser (`127.1` is `127.0.0.1`). */
2509
+ function isLoopbackHostname(hostname: string): boolean {
2510
+ return hostname === 'localhost' || hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(hostname)
2511
+ }
2512
+
2513
+ /** Ports a URL parser drops from a URL with this scheme. */
2514
+ const DEFAULT_PORTS: Record<string, string> = {
2515
+ 'http:': '80',
2516
+ 'https:': '443',
2517
+ 'ws:': '80',
2518
+ 'wss:': '443',
2519
+ 'ftp:': '21',
2520
+ }
2521
+
2522
+ /**
2523
+ * The lowercased scheme and host of a URL with a scheme, without userinfo or
2524
+ * one trailing dot, whether it has userinfo (an `@` in the authority), its
2525
+ * port (empty for the scheme's default) and its path with the query (`/` for
2526
+ * an empty path, without the fragment); undefined for a relative URL that
2527
+ * `base` does not resolve. A runtime URL parser that
2528
+ * follows the URL Standard canonicalizes first (`127.1`, IDN, `[0::1]`, dot
2529
+ * segments, percent-encoding), but the parts are read by hand: React Native's
2530
+ * global URL throws from `protocol` and `hostname` before 0.80, and from 0.80
2531
+ * reads them with regexes that neither lowercase nor end the authority at the
2532
+ * first `/`, so `https://evil.example/@host` would have the hostname `host`.
2533
+ * There the configured spelling is read, since its `href` appends a `/`.
2534
+ */
2535
+ function schemeAndHost(
2536
+ url: string,
2537
+ base?: string
2538
+ ): { protocol: string; hostname: string; userinfo: boolean; port: string; path: string } | undefined {
2539
+ const match = /^([a-z][a-z\d+.-]*:)[/\\]*([^/?#\\]*)([^#]*)/i.exec(standardHref(url, base) ?? url)
2540
+ if (!match) return undefined
2541
+ const protocol = match[1]!.toLowerCase()
2542
+ const at = match[2]!.lastIndexOf('@')
2543
+ const authority = match[2]!.slice(at + 1)
2544
+ const [, hostname, port] = /^(\[[^\]]*\]|[^:]*):?(.*)$/s.exec(authority)!
2545
+ return {
2546
+ protocol,
2547
+ hostname: hostname!.toLowerCase().replace(/\.$/, ''),
2548
+ userinfo: at !== -1,
2549
+ port: port === DEFAULT_PORTS[protocol] ? '' : port!,
2550
+ path: match[3]!.startsWith('/') ? match[3]! : `/${match[3]!}`,
2551
+ }
2552
+ }
2553
+
2554
+ /**
2555
+ * `new URL(url, base).href` where the runtime's URL parser follows the URL
2556
+ * Standard; undefined where it does not parse `url`, and on React Native.
2557
+ */
2558
+ function standardHref(url: string, base?: string): string | undefined {
2559
+ if (!followsUrlStandard()) return undefined
2560
+ try {
2561
+ return new URL(url, base).href
2562
+ } catch {
2563
+ return undefined
2564
+ }
2565
+ }
2566
+
2567
+ /**
2568
+ * Whether the runtime's URL parser follows the URL Standard (Node, browsers,
2569
+ * Deno, Bun). React Native's does not: its `href` is the input with a `/`
2570
+ * appended, and its other getters throw before 0.80.
2571
+ */
2572
+ function followsUrlStandard(): boolean {
2573
+ try {
2574
+ return new URL('HTTP://Q.EXAMPLE:80/a/../b').href === 'http://q.example/b'
2575
+ } catch {
2576
+ return false
2577
+ }
2578
+ }
2579
+
2580
+ /**
2581
+ * True when `response.url` is not the requested URL: another scheme, host,
2582
+ * port, or path and query. React Native's fetch ignores `redirect: 'manual'`:
2583
+ * the platform follows the redirect and the reply arrives with the final URL
2584
+ * in `url` but without `redirected`. A redirect within the backend is refused
2585
+ * too, since its reply answers another request (another transaction's status,
2586
+ * say). Both URLs are read with `schemeAndHost`, so the case of the scheme and
2587
+ * host, a default port and a trailing dot on the host do not count. When
2588
+ * either URL cannot be read, the reply counts as from another URL.
2589
+ */
2590
+ function fromAnotherUrl(response: Response, url: string): boolean {
2591
+ if (typeof response.url !== 'string' || response.url === '') return false
2592
+ const requested = schemeAndHost(url)
2593
+ const replied = schemeAndHost(response.url)
2594
+ if (!requested || !replied) return true
2595
+ return (
2596
+ replied.protocol !== requested.protocol ||
2597
+ replied.hostname !== requested.hostname ||
2598
+ replied.port !== requested.port ||
2599
+ replied.path !== requested.path
2600
+ )
2601
+ }
2602
+
2603
+ /**
2604
+ * Requests use `redirect: 'manual'`, so a redirect arrives here as a 3xx, or in
2605
+ * browsers as an `opaqueredirect` with status 0. Following it would send the
2606
+ * configured headers wherever `Location` points and accept that server's reply
2607
+ * as backend data. A fetch that ignores `redirect` and follows anyway reports
2608
+ * `redirected`, or a `url` other than the request's; its reply is refused too.
2609
+ */
2610
+ function refuseRedirect(response: Response, url: string, baseUrl: string): Response {
2611
+ const opaque = response.type === 'opaqueredirect'
2612
+ const redirectStatus = response.status >= 300 && response.status <= 399
2613
+ if (!opaque && !redirectStatus && response.redirected !== true && !fromAnotherUrl(response, url)) {
2614
+ return response
2615
+ }
2616
+ void discardResponseBody(response)
2617
+ const errorUrl = redactBaseUrl(url, baseUrl)
2618
+ if (opaque) {
2619
+ throw new QbitApiError('http', `redirect refused (target hidden by the browser): ${errorUrl}`, { url: errorUrl })
2620
+ }
2621
+ if (redirectStatus) {
2622
+ const target = redactRedirectTarget(redirectTargetOrigin(response.headers.get('location'), url), baseUrl)
2623
+ throw new QbitApiError('http', `HTTP ${response.status} redirect to ${target} refused: ${errorUrl}`, {
2624
+ status: response.status,
2625
+ url: errorUrl,
2626
+ })
2627
+ }
2628
+ const target = redactRedirectTarget(redirectTargetOrigin(response.url, url), baseUrl)
2629
+ throw new QbitApiError('http', `fetch followed a redirect to ${target}; reply discarded: ${errorUrl}`, {
2630
+ url: errorUrl,
2631
+ })
2632
+ }
2633
+
2634
+ /**
2635
+ * A redirect target as its origin only; its path and query may carry tokens.
2636
+ * Read with `schemeAndHost`, not the runtime's `origin`: React Native's throws
2637
+ * before 0.80 and from 0.80 keeps userinfo and a query. Where the runtime
2638
+ * cannot resolve a relative location, a scheme-relative one keeps the
2639
+ * request's scheme and any other keeps its origin.
2640
+ */
2641
+ function redirectTargetOrigin(location: string | null, url: string): string {
2642
+ if (!location) return 'an unspecified location'
2643
+ const target =
2644
+ schemeAndHost(location, url) ??
2645
+ schemeAndHost(/^[/\\]{2}/.test(location) ? `${schemeAndHost(url)?.protocol ?? ''}${location}` : url)
2646
+ if (!target) return 'an invalid location'
2647
+ if (!/^(?:https?|wss?|ftp):$/.test(target.protocol)) return `a ${target.protocol} URL`
2648
+ if (!/^(?:[a-z\d._~-]+|\[[a-f\d:.]+\])$/.test(target.hostname) || !/^\d*$/.test(target.port)) {
2649
+ return 'an invalid location'
2650
+ }
2651
+ return `${target.protocol}//${target.hostname}${target.port ? `:${target.port}` : ''}`
2652
+ }
2653
+
2654
+ /**
2655
+ * The base URL as errors show it: its origin, with `/...` in place of any path
2656
+ * or query, where hosted indexers often put an API token
2657
+ * (`https://host/<token>/api`). Errors get logged and reported. Read by hand
2658
+ * after the runtime's URL parser has normalized it, as in `schemeAndHost`:
2659
+ * React Native's `origin` throws before 0.80 and keeps credentials from 0.80.
2660
+ * `...` alone when the host cannot be read reliably.
2661
+ */
2662
+ function redactedBaseUrl(baseUrl: string): string {
2663
+ let href = baseUrl
2664
+ try {
2665
+ href = new URL(baseUrl).href
2666
+ } catch {
2667
+ // Not parsable here: read the configured spelling.
2668
+ }
2669
+ const match = /^(https?:)[/\\]*([^/?#\\]*)/i.exec(href)
2670
+ if (!match) return '...' // Not an http(s) URL: show none of it.
2671
+ const host = match[2]!.slice(match[2]!.lastIndexOf('@') + 1).toLowerCase()
2672
+ // A password with a raw `/`, `?`, `#` or `\` ends the authority early, so
2673
+ // the host read can be `user:password-prefix`, or (`apikey:8443/x@host`) a
2674
+ // username and a numeric password the URL parser takes for host and port.
2675
+ // Show only a plain host[:port] with no `@` after it.
2676
+ if (!/^(?:[a-z\d._~-]+|\[[a-f\d:.]+\])(?::\d*)?$/.test(host) || href.slice(match[0].length).includes('@')) {
2677
+ return '...'
2678
+ }
2679
+ const origin = `${match[1]!.toLowerCase()}//${host}`
2680
+ return basePath(href) ? `${origin}/...` : origin
2681
+ }
2682
+
2683
+ /**
2684
+ * `text` with the base URL replaced by `redactedBaseUrl`, both as configured
2685
+ * and as the URL parser normalizes it: fetch implementations quote either form
2686
+ * in their own error messages. The base URL's path and query become `/...`
2687
+ * where they appear on their own, as servers quote the request path (Express:
2688
+ * `Cannot GET /<token>/api/blocks/tip/height`), and each of `baseUrlParts`
2689
+ * becomes `...`, as a server may quote a token alone. One pass, longest match
2690
+ * first, so replaced text is not matched again.
2691
+ */
2692
+ function redactBaseUrl(text: string, baseUrl: string): string {
2693
+ const spellings = [baseUrl]
2694
+ try {
2695
+ spellings.push(new URL(baseUrl).href.replace(/\/+$/, ''))
2696
+ } catch {
2697
+ // Unparsable: only the configured spelling can appear.
2698
+ }
2699
+ const redacted = redactedBaseUrl(baseUrl)
2700
+ const replacements = new Map<string, string>()
2701
+ for (const part of spellings.flatMap(baseUrlParts)) replacements.set(part, '...')
2702
+ for (const path of spellings.map(basePath)) if (path) replacements.set(path, '/...')
2703
+ for (const spelling of spellings) if (spelling) replacements.set(spelling, redacted)
2704
+ if (replacements.size === 0) return text
2705
+ const pattern = [...replacements.keys()]
2706
+ .sort((a, b) => b.length - a.length)
2707
+ .map(needle => needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
2708
+ .join('|')
2709
+ return text.replace(new RegExp(pattern, 'g'), match => replacements.get(match)!)
2710
+ }
2711
+
2712
+ /**
2713
+ * Parts of a base URL that a server may quote on their own (`invalid api key
2714
+ * <token>`, a JSON-escaped `\/<token>\/` or `%2F`-joined path): each path
2715
+ * segment, query value and userinfo part of at least 8 characters, also
2716
+ * percent-decoded. Userinfo runs to the last `@` before the query, so a
2717
+ * password with a raw `/` is found whole, and path segments are read both
2718
+ * after the authority and after that `@`.
2719
+ */
2720
+ function baseUrlParts(url: string): string[] {
2721
+ const match = /^(?:[a-z][a-z\d+.-]*:[/\\]*)?([^?#]*)(?:\?([^#]*))?/i.exec(url)!
2722
+ const beforeQuery = match[1]!
2723
+ const at = beforeQuery.lastIndexOf('@')
2724
+ const userinfo = at === -1 ? '' : beforeQuery.slice(0, at)
2725
+ const colon = userinfo.indexOf(':')
2726
+ const parts = [
2727
+ ...(colon === -1 ? [userinfo] : [userinfo.slice(0, colon), userinfo.slice(colon + 1)]),
2728
+ ...beforeQuery.split(/[/\\]/).slice(1),
2729
+ ...beforeQuery.slice(at + 1).split(/[/\\]/).slice(1),
2730
+ ...(match[2] ?? '').split('&').map(pair => pair.slice(pair.indexOf('=') + 1)),
2731
+ ]
2732
+ return parts.flatMap(part => [part, percentDecoded(part)]).filter(part => part.length >= 8)
2733
+ }
2734
+
2735
+ function percentDecoded(text: string): string {
2736
+ try {
2737
+ return decodeURIComponent(text)
2738
+ } catch {
2739
+ return text
2740
+ }
2741
+ }
2742
+
2743
+ /** What follows the origin of an absolute URL; empty for a bare origin or a relative URL. */
2744
+ function basePath(url: string): string {
2745
+ const path = /^[a-z][a-z\d+.-]*:[/\\]*[^/?#\\]*(.*)$/i.exec(url)?.[1] ?? ''
2746
+ return path === '/' ? '' : path
2747
+ }
2748
+
2749
+ /**
2750
+ * A redirect target's origin with the base URL's parts redacted. The server
2751
+ * chose it, and a provider that moves a key from the path into a subdomain
2752
+ * would put the key in the message. Hosts are lowercased, so parts are matched
2753
+ * case-insensitively.
2754
+ */
2755
+ function redactRedirectTarget(target: string, baseUrl: string): string {
2756
+ const redacted = redactBaseUrl(target, baseUrl)
2757
+ const parts = [...new Set(baseUrlParts(baseUrl))].sort((a, b) => b.length - a.length)
2758
+ if (parts.length === 0) return redacted
2759
+ const pattern = parts.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
2760
+ return redacted.replace(new RegExp(pattern, 'gi'), '...')
2761
+ }
2762
+
2763
+ /** A response body preview: trimmed, the base URL redacted, capped at 200 characters. */
2764
+ function responseBodyPreview(text: string, baseUrl: string): string {
2765
+ return redactBaseUrl(text.trim(), baseUrl).slice(0, 200)
2766
+ }
2767
+
2768
+ /** Response text quoted in an error message: `responseBodyPreview` with control characters escaped. */
2769
+ function quotedResponseText(text: string, baseUrl: string): string {
2770
+ return escapeControlChars(responseBodyPreview(text, baseUrl))
2771
+ }
2772
+
2773
+ /** How many levels of nested errors and objects a redacted cause keeps. */
2774
+ const REDACTED_CAUSE_DEPTH = 5
2775
+
2776
+ /** Built-in error classes a redacted cause keeps, so `cause instanceof TypeError` still holds. */
2777
+ const NATIVE_ERRORS: (new (message?: string) => Error)[] = [
2778
+ Error,
2779
+ TypeError,
2780
+ RangeError,
2781
+ SyntaxError,
2782
+ ReferenceError,
2783
+ EvalError,
2784
+ URIError,
2785
+ ]
2786
+
2787
+ /** `QbitApiError` options for an underlying error: a redacted `cause`, the original kept off the error. */
2788
+ function causeOptions(cause: unknown, baseUrl: string): { cause: unknown; unredactedCause: unknown } {
2789
+ return { cause: redactedCause(cause, baseUrl, REDACTED_CAUSE_DEPTH), unredactedCause: cause }
2790
+ }
2791
+
2792
+ /**
2793
+ * A copy of an underlying error that is safe to log. Fetch implementations put
2794
+ * the full request URL in their errors (Bun's `path`, Node's messages for a
2795
+ * credentialed or relative URL, node-fetch's), and loggers print `cause`
2796
+ * (`console.error`, `util.inspect`, Sentry, pino). Strings pass through
2797
+ * `redactBaseUrl`: the message, the stack and every string property. Errors
2798
+ * (a nested `cause` included), arrays and plain objects are copied the same
2799
+ * way, `depth` levels deep, keeping each property's enumerability; deeper
2800
+ * values, accessors and other objects are left out. An error keeps its
2801
+ * `name`, and its class when that is a built-in one.
2802
+ */
2803
+ function redactedCause(value: unknown, baseUrl: string, depth: number): unknown {
2804
+ if (typeof value === 'string') return redactBaseUrl(value, baseUrl)
2805
+ if (typeof value === 'function') return undefined
2806
+ if (typeof value !== 'object' || value === null) return value
2807
+ if (depth === 0) return undefined
2808
+ if (Array.isArray(value)) return value.map(item => redactedCause(item, baseUrl, depth - 1))
2809
+ try {
2810
+ let copy: object
2811
+ const prototype: unknown = Object.getPrototypeOf(value)
2812
+ if (value instanceof Error) {
2813
+ const ErrorClass = NATIVE_ERRORS.find(errorClass => errorClass === value.constructor) ?? Error
2814
+ const error = new ErrorClass(redactBaseUrl(String(value.message), baseUrl))
2815
+ if (error.name !== value.name) {
2816
+ Object.defineProperty(error, 'name', { value: String(value.name), writable: true, configurable: true })
2817
+ }
2818
+ error.stack = typeof value.stack === 'string' ? redactBaseUrl(value.stack, baseUrl) : `${error.name}: ${error.message}`
2819
+ copy = error
2820
+ } else if (prototype === Object.prototype || prototype === null) {
2821
+ copy = {}
2822
+ } else {
2823
+ return undefined
2824
+ }
2825
+ for (const key of Object.getOwnPropertyNames(value)) {
2826
+ if (value instanceof Error && (key === 'message' || key === 'stack')) continue
2827
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
2828
+ if (descriptor === undefined || !('value' in descriptor)) continue
2829
+ const redacted = redactedCause(descriptor.value, baseUrl, depth - 1)
2830
+ if (redacted === undefined && descriptor.value !== undefined) continue
2831
+ Object.defineProperty(copy, key, {
2832
+ value: redacted,
2833
+ enumerable: descriptor.enumerable ?? false,
2834
+ writable: true,
2835
+ configurable: true,
2836
+ })
2837
+ }
2838
+ return copy
2839
+ } catch {
2840
+ return undefined
2841
+ }
2842
+ }
2843
+
2844
+ /**
2845
+ * Response text for an error message, with control characters (CR and LF
2846
+ * included), Unicode line separators and bidi controls escaped so a hostile
2847
+ * body cannot forge or reorder log lines. `responseBody` keeps them unescaped.
2848
+ */
2849
+ function escapeControlChars(text: string): string {
2850
+ return text.replace(/[\p{Cc}\p{Zl}\p{Zp}\p{Bidi_Control}]/gu, char => {
2851
+ if (char === '\n') return '\\n'
2852
+ if (char === '\r') return '\\r'
2853
+ if (char === '\t') return '\\t'
2854
+ return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
2855
+ })
2856
+ }
2857
+
2858
+ function formatErrorCause(error: unknown): string {
2859
+ if (error instanceof Error) {
2860
+ return ` (${error.name}: ${error.message})`
2861
+ }
2862
+ if (typeof error === 'string') {
2863
+ return ` (${error})`
2864
+ }
2865
+ return ''
2866
+ }
2867
+
2868
+ /**
2869
+ * Read a call's response body as text through `readBodyUntilAborted`, the one
2870
+ * body reader. If `signal` aborts first, reject at once with the call's
2871
+ * `aborted` / `timeout` error and cancel the body (best-effort, never
2872
+ * awaited); a failed read is `parse`. fetchOnce has already buffered the
2873
+ * response, so a de-duplicated GET's clone reads from memory. `url` is the
2874
+ * request URL as errors show it.
2875
+ */
2876
+ async function readBody(response: Response, url: string, baseUrl: string, signal: AbortSignal): Promise<string> {
2877
+ if (signal.aborted) {
2878
+ const error = abortErrorFor(signal)
2879
+ void discardResponseBody(response, error)
2880
+ throw error
2881
+ }
2882
+ try {
2883
+ return await readBodyUntilAborted(response, signal)
2884
+ } catch (cause) {
2885
+ if (signal.aborted) throw abortErrorFor(signal)
2886
+ throw bodyReadError(response, cause, url, baseUrl)
2887
+ }
2888
+ }
2889
+
2890
+ function bodyReadError(response: Response, cause: unknown, url: string, baseUrl: string): QbitApiError {
2891
+ return new QbitApiError('parse', `failed to read response body from ${url}`, {
2892
+ url,
2893
+ status: response.status,
2894
+ ...causeOptions(cause, baseUrl),
2895
+ })
2896
+ }
2897
+
2898
+ /**
2899
+ * Read the whole body as text, giving up as soon as `signal` aborts. The
2900
+ * stream is cancelled on failure, so a body that stalls after the headers
2901
+ * releases its connection even if the fetch implementation ignores the signal.
2902
+ */
2903
+ async function readBodyUntilAborted(response: Response, signal: AbortSignal): Promise<string> {
2904
+ let rejectAborted: (reason: unknown) => void = () => {}
2905
+ const aborted = new Promise<never>((_, reject) => {
2906
+ rejectAborted = reject
2907
+ })
2908
+ // Every path below races `aborted`; this only guards an early synchronous throw.
2909
+ aborted.catch(() => {})
2910
+ const onAbort = () => rejectAborted(signal.reason)
2911
+ if (signal.aborted) onAbort()
2912
+ else signal.addEventListener('abort', onAbort, { once: true })
2913
+
2914
+ try {
2915
+ const body = response.body
2916
+ if (!body || typeof body.getReader !== 'function' || typeof TextDecoder !== 'function') {
2917
+ try {
2918
+ return await Promise.race([response.text(), aborted])
2919
+ } catch (error) {
2920
+ if (signal.aborted) releaseBody(body, signal.reason)
2921
+ throw error
2922
+ }
2923
+ }
2924
+ const reader = body.getReader()
2925
+ const decoder = new TextDecoder()
2926
+ let text = ''
2927
+ try {
2928
+ while (true) {
2929
+ const chunk = await Promise.race([reader.read(), aborted])
2930
+ if (chunk.done) return text + decoder.decode()
2931
+ text += decoder.decode(chunk.value, { stream: true })
2932
+ }
2933
+ } catch (error) {
2934
+ reader.cancel(error).catch(() => {
2935
+ // Cancellation is best-effort; the read failure remains authoritative.
2936
+ })
2937
+ throw error
2938
+ }
2939
+ } finally {
2940
+ signal.removeEventListener('abort', onAbort)
2941
+ }
2942
+ }
2943
+
2944
+ /**
2945
+ * Best-effort, non-blocking release of a body whose `text()` lost the race to
2946
+ * the deadline, for a fetch implementation that ignores the signal once the
2947
+ * headers have arrived. A web stream is cancelled (a no-op if `text()` holds
2948
+ * its lock). A Node-style stream is destroyed with the reason, which settles
2949
+ * a consumer such as `text()` that listens for `'error'`; with no `'error'`
2950
+ * listener it is destroyed without one, since an unhandled `'error'` event
2951
+ * would crash the process.
2952
+ */
2953
+ function releaseBody(body: unknown, reason: unknown): void {
2954
+ if (body === null || typeof body !== 'object') return
2955
+ const stream = body as { cancel?: unknown; destroy?: unknown; listenerCount?: unknown }
2956
+ try {
2957
+ if (typeof stream.cancel === 'function') {
2958
+ const result = stream.cancel(reason) as { then?: unknown } | null | undefined
2959
+ if (result && typeof result.then === 'function') (result as PromiseLike<unknown>).then(undefined, () => {})
2960
+ } else if (typeof stream.destroy === 'function') {
2961
+ const handled = typeof stream.listenerCount === 'function' && Number(stream.listenerCount('error')) > 0
2962
+ if (handled) stream.destroy(reason)
2963
+ else stream.destroy()
2964
+ }
2965
+ } catch {
2966
+ // Cleanup is best-effort; the timeout remains authoritative.
2967
+ }
2968
+ }
2969
+
2970
+ /** Statuses whose responses carry no body; `new Response` rejects one for them. */
2971
+ const NULL_BODY_STATUSES = new Set([204, 205, 304])
2972
+
2973
+ /**
2974
+ * A fully buffered copy of `response`. A custom `fetchFn` (node-fetch on old
2975
+ * Node, React Native polyfills) may run where there is no global `Response`,
2976
+ * or one that rejects the fetched status, so this falls back to a minimal
2977
+ * Response-like object instead of failing the attempt.
2978
+ */
2979
+ function bufferedResponse(response: Response, text: string): Response {
2980
+ const body = NULL_BODY_STATUSES.has(response.status) ? null : text
2981
+ const buffered = constructedResponse(response, body) ?? plainBufferedResponse(response, body ?? '')
2982
+ // Constructed responses (and their clones) have an empty `url`; keep the
2983
+ // fetched one for errors, including on clones handed to de-duplicated GETs.
2984
+ // `headers` stays the fetched object, which may be a foreign or minimal
2985
+ // `Headers`, so it is never handed to the constructor to re-parse.
2986
+ Object.defineProperties(buffered, {
2987
+ url: { value: response.url },
2988
+ headers: { value: response.headers },
2989
+ clone: { value: () => bufferedResponse(response, text) },
2990
+ })
2991
+ return buffered
2992
+ }
2993
+
2994
+ function constructedResponse(response: Response, body: string | null): Response | undefined {
2995
+ if (typeof Response !== 'function') return undefined
2996
+ try {
2997
+ return new Response(body, { status: response.status, statusText: response.statusText })
2998
+ } catch {
2999
+ return undefined
3000
+ }
3001
+ }
3002
+
3003
+ /**
3004
+ * The subset of `Response` the client and `request()` callers rely on:
3005
+ * status fields, `text()`/`json()` with single-use `bodyUsed` semantics, and
3006
+ * (added by the caller) `url`, `headers` and `clone()`. `body` is `null`, as
3007
+ * there is no stream to read or cancel.
3008
+ */
3009
+ function plainBufferedResponse(response: Response, text: string): Response {
3010
+ let bodyUsed = false
3011
+ const consume = async (): Promise<string> => {
3012
+ if (bodyUsed) throw new TypeError('Body has already been consumed')
3013
+ bodyUsed = true
3014
+ return text
3015
+ }
3016
+ return {
3017
+ status: response.status,
3018
+ statusText: response.statusText,
3019
+ ok: response.status >= 200 && response.status <= 299,
3020
+ redirected: response.redirected ?? false,
3021
+ type: response.type ?? 'default',
3022
+ body: null,
3023
+ get bodyUsed() {
3024
+ return bodyUsed
3025
+ },
3026
+ text: consume,
3027
+ json: async () => JSON.parse(await consume()) as unknown,
3028
+ } as unknown as Response
3029
+ }
3030
+
3031
+ async function discardResponseBody(response: Response, reason?: unknown): Promise<void> {
3032
+ if (response.body === null || response.bodyUsed) return
3033
+ try {
3034
+ await response.body.cancel(reason)
3035
+ } catch {
3036
+ // Cleanup is best-effort; the retry result remains authoritative.
3037
+ }
3038
+ }
3039
+
3040
+ /** A reply as error messages quote it: `url` redacted, `detail` a `responseBodyPreview`. */
3041
+ function httpReplyMessage(status: number, url: string, detail: string): string {
3042
+ return `HTTP ${status} from ${url}${detail ? `: ${escapeControlChars(detail)}` : ''}`
3043
+ }
3044
+
3045
+ /**
3046
+ * Unredacted response previews, keyed by error. Only the SDK's own decisions
3047
+ * read them (the `/tx/push` fallback): redaction can rewrite words of a reply
3048
+ * when a base-URL part matches them.
3049
+ */
3050
+ const rawResponseBodies = new WeakMap<QbitApiError, string>()
3051
+
3052
+ function httpError(response: Response, body: string, url: string, baseUrl: string): QbitApiError {
3053
+ const status = response.status
3054
+ const detail = responseBodyPreview(body, baseUrl)
3055
+ const message = httpReplyMessage(status, url, detail)
3056
+ const options: QbitApiErrorOptions = { status, url, responseBody: detail }
3057
+ const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
3058
+ if (retryAfterMs !== undefined) options.retryAfterMs = retryAfterMs
3059
+ const error =
3060
+ status === 429
3061
+ ? new QbitApiError('rate_limit', message, options)
3062
+ : status === 413
3063
+ ? new QbitApiError('payload_too_large', message, options)
3064
+ : status === 404
3065
+ ? new QbitApiError('not_found', message, options)
3066
+ : new QbitApiError('http', message, options)
3067
+ rawResponseBodies.set(error, body.trim().slice(0, 200))
3068
+ return error
3069
+ }