@qbit-oss/api 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +467 -2
- package/lib/index.d.mts +551 -0
- package/lib/index.d.ts +551 -0
- package/lib/index.js +1921 -0
- package/lib/index.js.map +1 -0
- package/lib/index.mjs +1889 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +61 -4
- package/src/index.ts +3069 -0
package/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
|
-
#
|
|
1
|
+
# @qbit-oss/api
|
|
2
2
|
|
|
3
|
-
|
|
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.
|