@qbit-oss/api 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The qbit-js developers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,468 @@
1
- # Temporary Holding Version
1
+ # @qbit-oss/api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed [esplora](https://github.com/Blockstream/esplora/blob/master/API.md) REST
4
+ client for qbit wallets and apps. Talks to an electrs/esplora endpoint
5
+ **directly** against a configurable per-chain base URL — no centralized facade.
6
+
7
+ ```ts
8
+ import { EsploraClient } from '@qbit-oss/api'
9
+
10
+ const client = new EsploraClient({ baseUrl: 'https://esplora.example/regtest/api' })
11
+
12
+ const balance = await client.getAddressBalance(address) // { confirmedSats, mempoolSats, totalSats }
13
+ const utxos = await client.getAddressUtxos(address)
14
+ const history = await client.getAddressHistory(address) // { confirmed, mempool }
15
+ const fees = await client.getFeeTiers() // [{ target, feeRate }] ascending
16
+ const txid = await client.broadcastTx(rawTxHex) // see "Broadcasting" below
17
+ ```
18
+
19
+ For the `/api/v1` surface of mempool-qbit (qbit's port of the mempool explorer
20
+ backend, as served at `mempool.qbit.org/api`), use `MempoolQbitClient`. Its
21
+ `getFeeEstimates()` adapter maps the backend's three projected-block quotes to
22
+ targets 1, 2, and 3. `getMempoolRecommendedFees()` returns the original named
23
+ response, including `economyFee` and `minimumFee`; those values are policy
24
+ floors and are not presented as confirmation-target estimates.
25
+ Combined address-history pagination is capped at 256 requests per call by
26
+ default; pass `maxAddressHistoryPages` to set a different positive integer
27
+ limit. Exhaustion throws a `pagination_limit` `QbitApiError` rather than
28
+ returning silently truncated history.
29
+ `getAddressTxs(address, afterTxid)` scans from the current first page and
30
+ requires the exact confirmed address-history cursor before returning older
31
+ rows. A stale, unrelated, or unconfirmed cursor throws an `invalid_cursor`
32
+ `QbitApiError`.
33
+ If a volatile internal cursor disappears and the backend replays an earlier
34
+ full page, both history methods also throw `invalid_cursor` rather than return
35
+ incomplete results. Short final pages still complete normally.
36
+ Without `afterTxid`, an empty page right after a mempool row is checked against
37
+ the address stats: qbit-electrs answers that cursor with `[]` instead of the
38
+ confirmed rows that follow, so `getAddressTxs` throws `invalid_cursor` when the
39
+ stats count more confirmed transactions than it found.
40
+ Cursor-validation scan pages count against the same per-call limit; callers
41
+ that intentionally page deeper histories must raise `maxAddressHistoryPages`.
42
+
43
+ For complete scans, prefer `getAddressHistory(address, options)`. It returns
44
+ both `{ confirmed, mempool }` from page one without re-fetching mempool-qbit's
45
+ combined-history prefix for every 25 confirmed transactions. `EsploraClient`
46
+ fetches the mempool set once and exhausts `/txs/chain`; `MempoolQbitClient`
47
+ classifies one traversal of the combined `/txs` route. Neither implementation
48
+ assumes a fixed page size: completion requires an explicit empty response.
49
+
50
+ An empty response does not prove the history is complete. qbit-electrs answers
51
+ a cursor it cannot continue from with HTTP 200 `[]`: on a node that lags the
52
+ one that served the previous page (behind a load balancer, or mempool-qbit's
53
+ failover between indexers), after a reorg between pages, and on the combined
54
+ route when the cursor is the last mempool row (mempool-qbit then drops all
55
+ confirmed history whenever an address has 50, 100, … mempool transactions).
56
+ `/txs/mempool` lists at most 50 transactions. So `getAddressHistory` reads the
57
+ address stats (`/address/:address`) before and after the scan, each in a
58
+ request of its own, and returns only if both reads agree and the history holds
59
+ exactly `chain_stats.tx_count` confirmed and `mempool_stats.tx_count` mempool
60
+ transactions. If the stats changed during the scan, it scans once more; if they
61
+ change again, or the counts differ, it throws `invalid_cursor`. Retry such a
62
+ scan later. Some causes persist until the backend changes: on `EsploraClient`,
63
+ an address with more than 50 mempool transactions fails until enough confirm,
64
+ and on `MempoolQbitClient`, an address whose mempool rows exactly fill pages of
65
+ 50 fails while it has confirmed history. Stats without a non-negative
66
+ safe-integer `tx_count` throw `parse`.
67
+
68
+ `maxPages` defaults to 256 history page fetches including the terminal empty
69
+ response. It covers the whole call, so a rescan only gets the fetches the first
70
+ scan left. The two or three stats reads are not page fetches, nor is
71
+ `EsploraClient`'s `/txs/mempool` fetch.
72
+ Configured HTTP retries may add transport attempts for a page fetch.
73
+ On `MempoolQbitClient`, `maxPages` can lower but not raise the constructor's
74
+ `maxAddressHistoryPages` ceiling. `maxTransactions` independently defaults to
75
+ 6,400 raw rows for the whole call, a rescan included, and is checked before
76
+ duplicate detection; callers intentionally scanning larger histories must opt
77
+ into a higher value. Malformed rows,
78
+ transactions unrelated to the requested address, route/status mismatches,
79
+ duplicate txids, and non-advancing cursors fail closed.
80
+ These limits bound requests and post-parse processing/retention, not the byte
81
+ size of one HTTP response while it is being received and decoded.
82
+
83
+ ## Broadcasting
84
+
85
+ `broadcastTx(rawTxHex, { txid? })` submits a signed transaction and resolves
86
+ with its txid. Submitting is not idempotent from the caller's point of view, so
87
+ it does not use the read retry loop:
88
+
89
+ - **Accepted.** Resolves with the txid when a `2xx` reply echoes it, or when
90
+ the node reports the transaction as already in its mempool or already
91
+ confirmed (`txn-already-in-mempool`, `txn-already-known`, RPC error `-27`).
92
+ The code is read from the reply's structure first (esplora's plain-text
93
+ `sendrawtransaction RPC error: {...}`, a JSON-RPC `{"error":{"code":...}}`
94
+ envelope, or a flat `{"code":...}` object), so `-27` resolves even when
95
+ the message is localised or reworded. A repeated submission of the same
96
+ hex is therefore safe.
97
+ - **Rejected.** Coded validity rejections (`-26` `bad-txns-*`,
98
+ `txn-mempool-conflict`, decode failures, the backend's generic failure, or
99
+ a backend reporting it could not even connect to its node) throw an `http`
100
+ `QbitApiError` with the node's message. The transaction was not accepted.
101
+ The body is read before the HTTP status is judged: qbit Core's JSON-RPC
102
+ server answers `500` for every RPC error, so a relayed `5xx` whose body
103
+ carries the node's own code (`-22`, `-25`, `-26`, `-28`, …) or one of its
104
+ literal reject reasons is a definite rejection, and one carrying `-27` or an
105
+ already-known reason resolves with the txid.
106
+ - **Unknown.** When an attempt may have reached the node but produced no
107
+ definitive answer — a timeout, an abort or expired `deadlineMs` after the
108
+ POST may have been sent, an ambiguous network error, a redirect (see
109
+ "Redirects and transport security"), a reply that stalls or
110
+ cannot be read (other than a `429`), a `5xx` reply
111
+ whose body carries no verdict from the node (a gateway's HTML page, `-32603`,
112
+ a relayed transport code, no code at all), a
113
+ `2xx` reply that does not echo the txid (an empty body, a proxy's HTML page;
114
+ when no txid is known, any `2xx` body that is not itself a 64-hex txid),
115
+ or a backend relaying that its own call to the node timed out or was reset
116
+ (mempool-qbit's `{"code":"ETIMEDOUT"}` / `ESOCKETTIMEDOUT` / `ECONNRESET`,
117
+ its `-32603` / `-32602` for a node reply it could not read, qbit-electrs's
118
+ `Connection error`) — the client throws a `QbitApiError` of
119
+ kind `unknown_outcome` carrying `txid`, and never re-sends on its own. Poll
120
+ `getTxStatus(error.txid)` (a `not_found` means the node has not seen it
121
+ *yet*; give propagation a moment) and, once it is clearly absent, resubmit
122
+ the **same** hex. Never rebuild the payment with different inputs on
123
+ `unknown_outcome`: the original may already be in the mempool, and the
124
+ rebuilt transaction would double-spend or pay twice.
125
+
126
+ Only replies that prove the submission was not processed are retried: `429`
127
+ (honoring `Retry-After` up to `maxRetryAfterMs`, default 30 s) and connection
128
+ failures reported before anything was sent (`ECONNREFUSED`, `ENOTFOUND`,
129
+ connect timeouts, and `EHOSTUNREACH` / `ENETUNREACH` only from the `connect`
130
+ syscall). A `429` whose `Retry-After` exceeds `maxRetryAfterMs` is not waited
131
+ on: it rejects at once as `rate_limit` with `retryAfterMs` set, and the POST
132
+ is not re-sent. Browsers do not expose the failure stage, so a plain
133
+ `Failed to fetch` is treated as unknown.
134
+
135
+ The txid is derived from the hex (`txidFromRawTx`, witness-stripped double
136
+ SHA-256), so it is available even when the request never returned. Pass
137
+ `{ txid }` to supply it yourself; when both are available they must agree.
138
+ A `rawTxHex` that is not a string, or a `txid` option that is not a 64-hex
139
+ transaction id matching the hex, throws a `TypeError` before anything is
140
+ sent. Hex the parser cannot read, including encodings qbit Core refuses to
141
+ decode (a non-canonical or over-`MAX_SIZE` CompactSize, a witness flag with
142
+ every witness stack empty), is still sent as-is and left to the node; then
143
+ only a supplied `txid` can be attached to an `unknown_outcome`.
144
+
145
+ `MempoolQbitClient` falls back to the `/tx/push` form route only after `/tx`
146
+ reported that it did not submit (missing route or the backend's generic
147
+ failure). The fallback is a second submission and follows the same rules; an
148
+ uncertain `/tx` attempt never falls back.
149
+
150
+ ## Endpoints
151
+
152
+ `getAddressStats` · `getAddressUtxos` · `getAddressTxs` (paginated via
153
+ `/txs/chain[/:txid]`) · `getAddressMempoolTxs` · `getTx` · `getTxHex` · `getTxStatus` ·
154
+ `broadcastTx` · `getFeeEstimates` · `getTipHeight` · `getTipHash`, plus the
155
+ `getAddressBalance` / `getAddressHistory` / `getFeeTiers` adapters. `MempoolQbitClient` adapts the
156
+ mempool-qbit address pagination, recommended-fee, and broadcast fallback routes.
157
+
158
+ ## Resilience
159
+
160
+ - Per-request timeout (`AbortController`) covering both the response headers
161
+ and the full body, error bodies included; a body that stalls is cancelled.
162
+ For a read, not a submission, it fails as `timeout` (retried with backoff,
163
+ not `Retry-After`). A submission whose reply stalls or cannot be read is
164
+ never re-sent, since the node may already have it: it rejects with
165
+ `unknown_outcome` (see "Broadcasting"). The exception is a `429`, which
166
+ proves nothing was processed even when its body is lost: it is retried with
167
+ backoff, and rejects as `rate_limit` once retries run out.
168
+ - Retry with exponential backoff on network errors, timeouts, `5xx`, and `429`
169
+ (honoring `Retry-After` up to `maxRetryAfterMs`, default 30 s) for reads. When
170
+ the server asks for a longer wait, the client does not sleep: it throws the
171
+ `rate_limit` / `http` error immediately with `retryAfterMs` set so the UI can
172
+ decide when to try again. Submissions follow the stricter policy in
173
+ "Broadcasting": an attempt that may have reached the node is never re-sent.
174
+ - Cancellation and an overall deadline per call: every method accepts a final
175
+ `{ signal, deadlineMs }` options argument (`getAddressHistory` takes them
176
+ alongside `maxPages`/`maxTransactions`, `broadcastTx` alongside `txid`).
177
+ Both cover every dependent request of the call — pagination, retries,
178
+ backoff sleeps, and body reads — so a history scan stops fetching as soon as
179
+ either fires. An abort rejects with a new error of kind `aborted` whose
180
+ `cause` is the signal's reason; an expired `deadlineMs` rejects with kind
181
+ `timeout` and is not retried. A constructor `signal` cancels every call on
182
+ the client.
183
+ - **`broadcastTx`** (either client, including the mempool-qbit `/tx/push`
184
+ fallback): an abort or expired deadline before the POST is sent rejects with
185
+ `aborted` / `timeout`, and nothing was submitted. Once the POST may have been
186
+ sent — while it is in flight or its reply body is being read — the call
187
+ rejects with `unknown_outcome` carrying `txid`, with the `aborted` /
188
+ `timeout` error as `cause`; check `getTxStatus(error.txid)` before
189
+ resubmitting (see "Broadcasting"). The client never re-sends a POST after an
190
+ abort, an expired deadline, or an attempt that hit `timeoutMs`. A `429`
191
+ reply is the exception: it proves nothing was submitted, so an abort or
192
+ deadline while its body is read rejects with `aborted` / `timeout`, and a
193
+ `429` whose body hit `timeoutMs` is retried like any other `429`.
194
+ - An abort or expired deadline while a response body is still streaming
195
+ cancels that body stream, releasing the connection. For de-duplicated
196
+ `GET`s, the shared body is cancelled only once every caller reading it has
197
+ given up.
198
+ - In-flight de-duplication of identical concurrent `GET`s (polling-friendly).
199
+ One caller aborting stops only its own wait; the shared request is cancelled
200
+ only when every caller waiting on it has given up.
201
+ - Fetch credentials default to `omit`; authenticated reverse proxies can opt
202
+ into `same-origin` or `include` with the `credentials` option.
203
+ - Typed `QbitApiError` taxonomy for UI: `rate_limit` (429), `payload_too_large`
204
+ (413), `not_found` (404), `http`, `network`, `timeout`, `aborted`, `parse`,
205
+ `pagination_limit` (a history scan hit `maxPages` / `maxTransactions`),
206
+ `invalid_cursor` (a history scan could not be shown complete; retry it), and
207
+ `unknown_outcome` (a submission may have reached the node; carries `txid`).
208
+ - `QbitApiError.is(error)` recognizes a `QbitApiError` from any copy of this
209
+ package. Use it instead of `instanceof` where the app can load both the
210
+ CommonJS and the ESM build (an ESM app with a CommonJS dependency, or a
211
+ bundler mixing `import` and `require`), or two versions: each copy has its
212
+ own class, and `instanceof` is false for an error from the other one. It
213
+ checks the `Symbol.for('qbit-js.QbitApiError')` brand on the class
214
+ prototype. Any code can set that brand, so it classifies errors, it does not
215
+ authenticate them. An error from another version can carry a `kind` this one
216
+ does not list, so keep a default branch. `QbitApiError.unredactedCause` only
217
+ finds the cause of an error from its own copy.
218
+
219
+ ```ts
220
+ const controller = new AbortController()
221
+ const history = client.getAddressHistory(address, {
222
+ signal: controller.signal, // e.g. aborted when the user switches address
223
+ deadlineMs: 30_000, // whole scan, including retries and backoff
224
+ })
225
+ ```
226
+
227
+ ## Redirects and transport security
228
+
229
+ - The SDK does not follow redirects. Every request (`GET` and `POST`) is sent
230
+ with `redirect: 'manual'`, and a `3xx` reply (in browsers, an opaque
231
+ redirect) rejects with an `http` `QbitApiError`; `status` is the `3xx` code
232
+ where the runtime exposes it, and the message names only the target's
233
+ origin, never its path or query. Following a redirect would send your
234
+ `headers` (API keys included) to whatever `Location` names, possibly over
235
+ plain `http`, accept that server's reply as backend data, and turn a
236
+ `broadcastTx` `POST` into a `GET`. If an endpoint has moved, point `baseUrl`
237
+ at its new location.
238
+ - Some runtimes ignore `redirect: 'manual'` and follow the redirect
239
+ themselves; React Native's global `fetch` does. There the SDK can only
240
+ refuse the reply: a reply the runtime reports as `redirected`, or whose
241
+ `url` is not the requested URL, rejects with an `http` `QbitApiError` and
242
+ its body is discarded. Any difference in scheme, host, port, path or query
243
+ counts, so a redirect to another path on the same backend is refused too:
244
+ its reply answers another request. The case of the scheme and host, a
245
+ default port and a trailing dot on the host do not count. The redirected
246
+ request has already been sent by then. On Android it carries your custom
247
+ `headers` (an `x-api-key`, say) to the target; on iOS custom headers are
248
+ dropped, but the request is still sent. If your `headers` need protecting on
249
+ such a runtime, pass a `fetchFn` that honours `redirect: 'manual'`. The same
250
+ applies to any custom `fetchFn`.
251
+ - On `broadcastTx` (either client) a refused redirect of any kind rejects with
252
+ `unknown_outcome` carrying `txid` instead, with the `http` refusal as
253
+ `cause`. The `POST` reached the backend, which may have accepted it before
254
+ redirecting (a `303` can follow an accepted submission), and a runtime that
255
+ followed the redirect may have re-sent it to the target (`307` / `308`). It
256
+ is never re-sent, retried or sent to the `/tx/push` fallback: check
257
+ `getTxStatus(error.txid)` as for any `unknown_outcome`.
258
+ - A custom `fetchFn` must return a `Response` whose `url` is the URL it was
259
+ given, or empty. A wrapper that rewrites the request URL (to add a query
260
+ parameter or a path prefix, say) and returns the rewritten `url` has every
261
+ reply refused as a followed redirect. Rewrite the URL inside the wrapper,
262
+ then return a `Response` that reports the original URL or none.
263
+ - Tip: on React Native, send an API key in the `Authorization` header if your
264
+ backend accepts it. Android's HTTP client drops `Authorization` on a
265
+ redirect to another origin, though it forwards other custom headers, and
266
+ iOS drops all custom headers.
267
+ - An `http:` `baseUrl` is rejected by the constructor (an `http`
268
+ `QbitApiError`) unless its host is loopback (`localhost`, `127.0.0.0/8`,
269
+ `[::1]`) or you pass `allowInsecureHttp: true`. Plain `http` exposes request
270
+ headers and every response to the network.
271
+
272
+ ## Logging errors
273
+
274
+ `QbitApiError.url` never contains the base URL's path, where hosted indexers
275
+ often put an API token (`https://host/<token>/api`); the constructor rejects
276
+ credentials and a query in `baseUrl`. It shows the base as its origin followed
277
+ by `/...`, then the endpoint path, e.g. `https://host/.../blocks/tip/height`.
278
+ A base URL without a path is shown as its origin
279
+ (`https://host/blocks/tip/height`: lowercased, without a default port). A base
280
+ URL whose host cannot be read reliably is shown as `...`
281
+ (`.../blocks/tip/height`): one with an `@` after its host, or with a host or
282
+ port that is not plain ASCII letters, digits and `.-_~` (an IPv6 address in
283
+ brackets is fine). `message` names the request URL the same way. This holds
284
+ for every error the client creates: HTTP, `rate_limit`, `network`, `timeout`
285
+ (an attempt's, including a body that stalls, or a call's deadline), `parse`
286
+ (including a body that cannot be read), `aborted`, a refused redirect and
287
+ `unknown_outcome`.
288
+
289
+ Text the client quotes from elsewhere is redacted too, as far as it can be
290
+ recognized: fetch's own error text in `network` and `timeout` messages,
291
+ response text in `message` and `responseBody` (an HTTP error preview, a
292
+ submission reply in an `unknown_outcome`, a malformed tip height, a history
293
+ cursor), and `cause` (below). In it, the base URL becomes the form above; its
294
+ path becomes `/...` where a server quotes it on its own (Express:
295
+ `Cannot GET /<token>/api/...`); and each path segment of 8 or more characters
296
+ becomes `...` wherever it appears, as when a server quotes a key alone, a
297
+ JSON-escaped `\/<token>\/` path or a `%2F`-joined one. Matching is exact and
298
+ case-sensitive, against the base URL as configured, as the URL parser
299
+ normalizes it and, for those parts, percent-decoded. It runs before quoted
300
+ text is cut to 200 characters. A secret quoted any other way (re-cased,
301
+ re-encoded or split up, or a part shorter than 8 characters) is not caught, so
302
+ where you can, send an API key in a header rather than in the base URL. Parts
303
+ that are not secret are replaced too when they are 8 or more characters long
304
+ (a `testnet4` path segment, say).
305
+
306
+ Response text quoted in `message` also has control characters, line
307
+ separators and bidi controls escaped (`\r`, `\n`, `\u001b`, ...) so a hostile
308
+ body cannot forge log lines. `responseBody` keeps them unescaped.
309
+
310
+ `cause` is a redacted copy of the underlying error: its message, stack and
311
+ string properties (such as Bun's `path`) and its nested causes are redacted
312
+ the same way, and it keeps the error's `name` (and its class, for built-in
313
+ errors). So `console.error(error)`, `util.inspect` and error reporters that
314
+ follow `cause` print it redacted. The original error is not stored on the
315
+ `QbitApiError`, so no logger, serializer or `util.inspect(error, { showHidden:
316
+ true })` reaches it; read it with `QbitApiError.unredactedCause(error)`, and
317
+ do not log it where the base URL is secret. A `QbitApiError` that is itself
318
+ the cause (a timeout inside an `unknown_outcome`, say) was redacted when it
319
+ was created and is kept as it is.
320
+
321
+ Two values are your own and pass through unchanged: an `aborted` error's
322
+ `cause` is your signal's `reason`, and a `QbitApiError` thrown by a custom
323
+ `fetchFn` propagates as it is (or becomes the `cause` of an
324
+ `unknown_outcome`), so redact those yourself if they can carry the base URL.
325
+ That includes a `QbitApiError` from another copy of this package (the other
326
+ build, recognized as `QbitApiError.is` does). The client judges such an error
327
+ by its public fields only, since its original cause and response body stay
328
+ private to the copy that created it: on a submission, a `network` error from
329
+ another copy never counts as proof that nothing was sent (it becomes
330
+ `unknown_outcome`), and its response body never triggers the `/tx/push`
331
+ fallback.
332
+
333
+ ## Scope
334
+
335
+ This package intentionally carries none of the legacy centralized surface: no
336
+ asset/token endpoints, no remote wallet config, and no device-id/channel/client
337
+ telemetry headers. A test guard enforces that by construction.
338
+
339
+ ## Response validation
340
+
341
+ The fee and balance adapters reject an implausible backend response with a
342
+ `parse` `QbitApiError` instead of throwing a `TypeError` or returning `NaN`, a
343
+ silently dropped entry or an inflated value. One invalid entry rejects the
344
+ whole response.
345
+
346
+ `getTxHex(txid)` (either client) returns the hex only if it is the requested
347
+ transaction. The reply must be non-empty, even-length hex that decodes to one
348
+ complete transaction, and that transaction's txid must equal `txid`, compared
349
+ case-insensitively. The txid is the double SHA-256 of the witness-stripped
350
+ serialization, as qbit Core computes it (`CTransaction::ComputeHash`), so ask
351
+ by txid, not wtxid. It must also be encoded as qbit Core's decoder requires,
352
+ witness section included: each count and length in its shortest CompactSize
353
+ form and at most `MAX_SIZE` (0x02000000), and no witness flag when every
354
+ witness stack is empty (a stack holding one empty item is not empty); the
355
+ same rules apply to `txidFromRawTx`. Spaces, tabs and line breaks around the
356
+ hex are ignored, such as the line break qbit Core's REST interface appends.
357
+ Anything else throws a `parse` `QbitApiError`, and the hex is returned in
358
+ lowercase.
359
+ mempool.qbit.org currently answers this route with `200` and an empty body, so
360
+ `getTxHex` throws there until the backend is fixed. The txid does not commit
361
+ to witness data, so a reply whose witnesses were altered or stripped still
362
+ passes; compare the wtxid if you rely on them.
363
+
364
+ `getFeeTiers()` and `feeTiersFromEstimates()` require every confirmation
365
+ target to be a decimal block count from 1 to 1008 (the longest horizon Core's
366
+ fee estimator tracks) and every rate to be a finite number above 0 and at most
367
+ a fee-rate ceiling. The default ceiling, `DEFAULT_MAX_FEE_RATE_SAT_VB`
368
+ (10,000 sat/vB), is qbit Core's default `sendrawtransaction` `maxfeerate`.
369
+ It is a ceiling, not a sanity check: it only rejects rates that neither
370
+ esplora nor mempool-qbit could broadcast, so a hostile or broken backend can
371
+ still return 9,999 sat/vB. Set a lower ceiling for your wallet, cap the total
372
+ fee before signing, or both. To set a ceiling, fetch the raw estimates and
373
+ pass it:
374
+
375
+ ```ts
376
+ // 500 sat/vB leaves room for fee spikes and is 1/20 of the default worst case.
377
+ const tiers = feeTiersFromEstimates(await client.getFeeEstimates(), { maxFeeRate: 500 })
378
+ ```
379
+
380
+ `MempoolQbitClient` also requires `fastestFee`, `halfHourFee` and `hourFee` to
381
+ be finite numbers above 0.
382
+
383
+ `getAddressBalance()` and `addressBalanceFromStats()` require a string
384
+ `address`, `funded_txo_sum` and `spent_txo_sum` that are non-negative safe
385
+ integers in both `chain_stats` and `mempool_stats`, a confirmed balance that is
386
+ not negative, and a `totalSats` that is a non-negative safe integer.
387
+ `mempoolSats` may still be negative while mempool transactions spend confirmed
388
+ outputs.
389
+
390
+ `funded_txo_sum` and `spent_txo_sum` are lifetime totals of everything the
391
+ address has received and spent, not balances, and Core's `MAX_MONEY` (2.1e16
392
+ sats) does not bound them. `JSON.parse` silently rounds integers above
393
+ `Number.MAX_SAFE_INTEGER` (2^53 - 1 sats, about 90M QBT), so a larger sum
394
+ fails closed instead of producing a plausible but wrong balance. A reused
395
+ address that has received more than about 90M QBT over its life therefore
396
+ fails on every call, however little it holds now. For such an address, sum
397
+ the `value` of each output from `getAddressUtxos()` instead, checking that
398
+ each value is a non-negative safe integer, because that raw endpoint is not
399
+ validated.
400
+
401
+ On electrs, a negative-total `parse` error can be transient right after a
402
+ block. electrs applies the block's headers before indexing it and does not
403
+ deduplicate mempool stats against the chain, so a spend that was just
404
+ confirmed can count in both `chain_stats` and `mempool_stats` for a moment.
405
+ Retry on the next poll.
406
+
407
+ ## Configuration
408
+
409
+ ```ts
410
+ new EsploraClient({
411
+ baseUrl, // required; absolute http(s) URL
412
+ fetchFn, // default: global fetch
413
+ allowInsecureHttp, // default false; accept plain http to a non-loopback host
414
+ credentials, // default: 'omit'; accepts 'same-origin' or 'include'
415
+ timeoutMs, // default 15000; integer 1..2147483647
416
+ retries, // default 2; integer 0..10 (reads; submissions only repeat unsent attempts)
417
+ retryBaseDelayMs, // default 300; finite 0..2147483647
418
+ maxRetryAfterMs, // default 30000; integer 0..2147483647; longest Retry-After to sleep on
419
+ headers, // optional extra request headers
420
+ signal, // optional AbortSignal that cancels every call
421
+ sleepFn, // optional (ms, signal) => Promise<void> backoff sleep
422
+ })
423
+ ```
424
+
425
+ The constructor throws a `QbitApiError` for out-of-range options instead of
426
+ storing them. `timeoutMs`, `retries`, `retryBaseDelayMs` and `maxRetryAfterMs`
427
+ reject `null`; only an omitted option takes its default. `baseUrl` must not contain
428
+ whitespace, control characters (C0, DEL or C1), invisible format or
429
+ default-ignorable characters (such as U+00AD, U+200B or U+FE0F; ZWNJ/ZWJ stay
430
+ allowed in IDN hosts only), backslashes, credentials, a query or a fragment,
431
+ because endpoint paths are appended to it. A plain `http` URL also needs a
432
+ loopback host or `allowInsecureHttp: true` (see "Redirects and transport
433
+ security"). These checks read the URL by hand rather than through the
434
+ runtime's `URL` getters, which throw on React Native before 0.80, so they run
435
+ there too. Other non-ASCII characters are
436
+ accepted: an internationalized host is sent as punycode and path characters
437
+ are percent-encoded as UTF-8. On React Native without a standard `URL`
438
+ implementation, though, the SDK compares each reply's `url` with `baseUrl` as
439
+ you spelled it, and the native stack reports the canonical form. There, give
440
+ `baseUrl` in its ASCII form (a punycode host, a percent-encoded path, no `.` or
441
+ `..` segments), or every reply is refused as a followed redirect. Retry backoff
442
+ is capped at 2147483647 ms, the largest delay `setTimeout` supports.
443
+
444
+ `credentials` is typed `QbitFetchCredentials` (`'omit' | 'same-origin' |
445
+ 'include'`, the values of the DOM's `RequestCredentials`), so the type
446
+ declarations compile without the DOM lib, for example with `lib: ["ES2022"]`
447
+ and `types: ["node"]`. They still name the global `fetch`, `Response` and
448
+ `AbortSignal` types, which the DOM lib provides, and `@types/node` does from
449
+ 18.18.7 in 18.x, from 20.8.4 in 20.x, and in every release from 22 on. Without
450
+ the DOM lib, an older `@types/node` (16.x, 18.17, 20.8.0, say) fails with
451
+ `Cannot find name 'fetch'` (TS2304) in this package's declarations.
452
+
453
+ A custom `fetchFn` (for example node-fetch, or a React Native polyfill) only
454
+ needs to return a Response-like object with `status`, `statusText`, `url`,
455
+ `headers.get()` and `text()` (or a readable `body` stream); no global
456
+ `Response` is required. Where the runtime has no usable `Response`
457
+ constructor, `request()` resolves with a buffered stand-in that supports the
458
+ status fields, `headers`, `url`, `text()`, `json()` and `clone()`, with
459
+ `body: null`.
460
+
461
+ A custom `fetchFn` should honour `init.signal` for the whole response, aborting
462
+ the body read when the signal fires after the headers have arrived. The client
463
+ stops waiting at the deadline either way, but it can only release what it can
464
+ reach: it cancels a web `ReadableStream` body it reads itself, and when it falls
465
+ back to `text()` it makes a best-effort `body.cancel()` (web stream) or
466
+ `body.destroy()` (Node stream). A `text()`-only response with no `body`, or a web
467
+ stream that `text()` has already locked, is only released if `fetchFn` honours the
468
+ signal.