Skip to contentQbit Wallet is live — add it to Chrome
← All releases
Libraries

qbit-js 0.1.1

Release notes

This release fixes bugs, most of them where the packages did not do what qbit Core does, adds the @qbit-oss/api/testing entry point and the maxResponseBytes client option, and documents every public export. Entries marked Behaviour change can make existing code fail or behave differently, as RELEASING.md allows in a patch:

  • PSBT refusals: transaction shapes and PSBT keys that qbit Core refuses;
  • descriptor objects that reach one node twice, refused because such a tree can make the walk run without bound (a leaf object reused at two positions is refused with them, so give each position its own object);
  • React Native baseUrls that never worked;
  • maxResponseBytes, a new limit;
  • broadcastTx outcomes reported as the reply shows them;
  • the @noble/hashes minimum;
  • the unknown_outcome message text.

@qbit-oss/protocol

  • createP2mrPsbt, precomputeSighashMidstates, validateP2mrSigningPlans, signP2mrPsbt and extractP2mrTransaction handle transactions with any number of inputs and outputs, as do p2mrSighash and schnorrSignatureHash without cached midstates. They spread one call argument per input, output or witness item, so past about 120,000 of them they threw a bare RangeError on Node's main thread. A 1-input, 131,000-output PSBT that parsePsbt accepts at the default limits could not be signed (the signer was never called) or extracted, and a 40,000-input PSBT could not be extracted. Output bytes and signature hashes are unchanged.
  • decodePsbt, and so finalizeP2mrPsbt, validateP2mrSigningPlans and signP2mrPsbt, checks a PSBT's keys for duplicates by the ids parsePsbt uses, so a key over 64 bytes is compared by its SHA-256. It built the full hex of every key, and V8 hashes a string longer than 16,383 characters by its length alone, so 2,000 distinct 8 KiB keys, within the default limits, took 2.5 to 3.7 s in our measurements instead of about 60 ms, and held twice the key bytes in strings on the heap. Results and errors are unchanged.
  • combinePsbts accepts a map key over 64 bytes, such as a 65-byte script-sig key, that is a Uint8Array subclass from another realm (vm, iframe, worker), for example a Buffer, or with @noble/hashes before 1.3.3 any Uint8Array from another realm. It threw a bare TypeError from @noble/hashes, which rejects such arrays.
  • Behaviour change: Requires @noble/hashes ~1.8.0 (was ^1.3.0) and imports its sha2 entry point instead of the deprecated sha256 one. A fresh install already resolved 1.8.0, and a lockfile that keeps an older 1.x now gets a separate 1.8.x copy. An overrides or resolutions entry still forces its version, and @noble/hashes has no sha2 entry point before 1.3.3, so this release fails to load with an override below 1.3.3. @noble/hashes 1.8.0 declares engines node: ^14.21.3 || >=16, where 1.3.0 declared none.
  • Behaviour change: serializeP2mrDescriptor, p2mrRootFromDescriptor, p2mrAddressFromDescriptor and hasP2mrStandardSatisfaction throw INVALID_DESCRIPTOR for a descriptor object whose tree reaches one node object twice, through a shared subtree or a cycle. They bounded their walk of an object's tree by depth alone and followed every path, so 100 levels of { type: 'branch', left: prev, right: prev }, 101 objects with 2^100 paths, passed the depth check and never finished. Each object is now visited once. A cycle fails with this error instead of the depth one, and a tree that places one leaf object at two positions is refused too: give each position its own object. Trees from parseP2mrDescriptor never share nodes.
  • Behaviour change: createP2mrPsbt, validateP2mrSigningPlans and signP2mrPsbt throw INVALID_PSBT for a transaction without inputs or outputs, with two inputs spending the same outpoint, or with an input spending the null outpoint, which qbit Core's CheckTransaction rejects (bad-txns-vin-empty, bad-txns-vout-empty, bad-txns-inputs-duplicate, bad-txns-prevout-null; as the only input, a null outpoint makes a coinbase, which Core rejects too). They built such a PSBT (one without inputs then failed parsePsbt), and signing called the signer for every plan, spending stateful SLH-DSA key uses on a transaction no node accepts. The signing checks run before any signer call. parsePsbt and decodePsbt still accept such a PSBT, as Core's PSBT decoder does, except one without inputs, which parsePsbt already refused.
  • Behaviour change: parsePsbt, combinePsbts and decodePsbt (and so finalizeP2mrPsbt, validateP2mrSigningPlans and signP2mrPsbt) throw INVALID_PSBT for a key with data after a type byte qbit Core requires alone, as Core does ("key is more than one byte type"): global 0x00 and 0xfb; input 0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x13, 0x17 and 0x18; output 0x00, 0x01, 0x05 and 0x06. Such keys were kept, and the helpers match these types only as one-byte keys, so an input with an 08 00 record decoded as not finalized, and finalizeP2mrPsbt appended a real 0x08 beside it, producing a PSBT Core rejects.

@qbit-oss/api

  • New @qbit-oss/api/testing entry point, for tests only: createMockEsplora returns an offline mock of the qbit-electrs or mempool-qbit backend to pass to a client as fetchFn. It serves every route EsploraClient and MempoolQbitClient call from a declarative fixture, answers as the chosen backend does where they differ (an unknown txid's status is 200 {"confirmed":false} on qbit-electrs and 404 on mempool-qbit), records each request, and injects per-route faults: an HTTP status with Retry-After, a refused connection, a stalled body, and a submission that is accepted, rejected with a node code, or accepted with its reply lost. Without a fault, a submission gets qbit Core v1.0.0's sendrawtransaction verdict, with Core's code and message (for a conflict, Core's reason and the start of its message, without the feerates or fees Core compared), from the outputs the fixture and earlier submissions create and spend, in Core's order: -22 for hex Core cannot decode, -25 for an unspendable output with a value (maxburnamount), -27 for a transaction in a block while one of its outputs is unspent, -26 for a broken CheckTransaction rule, -25 for a spend of an output that is not a coin and for a transaction a block spends from, which is in the chain, -26 for a broken CheckTxInputs rule (an immature coinbase output, input values out of range, outputs worth more than the inputs), -26 for a conflict with a mempool transaction, which the mock never takes as a replacement, -26 for a spend of a mempool transaction's unspendable output, which Core's script check refuses, and -25 for a fee above the default maxfeerate. It does not model relay policy or standardness, fee floors, other scripts or signatures, lock times or sequence locks, qbit's P2MR-only output rule, mempool limits or replacement. A submission it takes joins the address views, in the mempool until mine() confirms it; they follow qbit-electrs, which lists the mempool oldest first and by default indexes no provably unspendable output, such as an OP_RETURN one, and matches a qbit bech32 address in lowercase or uppercase alike, refusing one in mixed case. A coinbase, in the fixture (declared or found from its hex) or taken through a fault, is served with its coinbase input. Where a fault makes the node take what qbit Core never would, such as a coinbase, a spend of an output it does not know, or a transaction a block spends from, the mock places it so that no block spends from the mempool. Its options are validated up front: null, a Retry-After that Headers refuses, and a fixture qbit Core could not hold, such as a spend of an unspendable or immature coinbase output or a block with two coinbases, are refused, not taken. It ships in both builds with type declarations, and imports nothing from the client at runtime. A testing/package.json shim points resolvers that ignore exports at it: TypeScript's moduleResolution: "node10" through types, and Jest before 28 (given a test environment with Response, Headers and ReadableStream) and Metro without package exports through main. Like @qbit-oss/api, it is built for ES2020, which webpack 4's parser does not support. @qbit-oss/api itself resolves as before. See Testing.
  • Behaviour change: The unknown_outcome error message ends "may have been accepted; resubmit the same hex, and never rebuild the payment with different inputs" (or "resubmit the same hex with its { txid }" when no txid is known) instead of "may have been accepted, check its status before resubmitting": qbit-electrs reports an unknown txid as unconfirmed, so a status check cannot settle the outcome. The error's kind and fields are unchanged.
  • Behaviour change: Requires @noble/hashes ~1.8.0 (was ^1.3.0) and imports its sha2 entry point instead of the deprecated sha256 one, as @qbit-oss/protocol does, with the same effect on lockfiles and overrides. @noble/hashes 1.8.0 declares engines node: ^14.21.3 || >=16, where 1.3.0 declared none.
  • Behaviour change: New client option maxResponseBytes, new error kind response_too_large, and a new export, DEFAULT_MAX_RESPONSE_BYTES (64 MiB), the option's default. Response bodies were read without a size limit until the attempt timed out (15 s by default, on each retry), so a hostile or broken backend that streamed without end could push hundreds of MB into a browser or mobile wallet, and every de-duplicated caller copied the reply again. The client now counts each body's bytes as they arrive and cancels the body once it passes the limit: a WHATWG ReadableStream body, or an async iterable one such as node-fetch's Node Readable, which it destroys. A read over it fails with response_too_large and is not retried, and de-duplicated reads share one request, one limit and one copy of the body. A submission's reply over it, a 429 included, is unknown_outcome, since the node may have accepted the transaction, and is not re-sent; a custom fetchFn's own response_too_large on a submission is unknown_outcome too. Where the response has no such body, the limit is checked in bytes on the whole body, so it fails the call but cannot stop the download: on React Native's fetch, against the blob()'s size, before its text is read (not with arrayBuffer(), which React Native 0.71 and earlier cannot read), else after arrayBuffer(). A Response-like object with only text() is measured as its text's UTF-8 encoding. An error keeps at most 200 characters of a body, as a copy that does not keep the body alive; so a JSON parse error's QbitApiError.unredactedCause is now a copy of JSON.parse's SyntaxError, with its message and stack, since V8's own holds the whole body. The default holds every reply about one transaction, for any transaction a block can hold, and a full history page of the largest standard spends; see Configuration. Code that switches on kind meets response_too_large in its default branch.
  • getAddressHistory, on both clients, fetches its history pages in requests of their own, as it already did its address-stats reads, instead of joining an identical GET already in flight. A joined page could have been answered before the first stats read, so after a replacement (RBF) that paid the address the same, which leaves the stats unchanged, a verified history could list the evicted transaction and miss its replacement. EsploraClient still reads the pages through getAddressMempoolTxs and getAddressTxs, so a subclass's overrides of them still apply.
  • MempoolQbitClient.getAddressTxs compares its afterTxid cursor with the backend's txids case-insensitively, as EsploraClient.getAddressTxs takes it. An uppercase or mixed-case cursor never matched qbit-electrs's lowercase txids, so the call threw invalid_cursor. A cursor that is not a string (from untyped input) still rejects as invalid_cursor, now before any request.
  • Behaviour change: Where the runtime's URL parser does not follow the URL Standard (React Native), the client constructors throw an http QbitApiError for a baseUrl whose host is not ASCII, asking for its punycode (xn--) form, or that has any other character RFC 3986 allows only percent-encoded (a non-ASCII path character, a space, ^, {, |, a % that starts no escape, say), asking for its %XX UTF-8 escapes. They accepted it, but the native stack requests and reports the encoded URL, so every reply was refused as a followed redirect, and every broadcastTx ended as unknown_outcome.
  • Behaviour change: broadcastTx, on both clients, ends as unknown_outcome, without re-sending, when a 429's body cannot be read in full: it fails, stalls past timeoutMs, passes maxResponseBytes, or an abort or the deadline cuts it short. Such a 429 was taken as proof that nothing was processed: it was re-sent while retries remained, and then rejected as rate_limit, or as aborted / timeout. But the outcome table lets a 429's body overrule its status (an already-known body resolves, a relayed transport failure is unknown_outcome), so an unread one may have reported that the node has, or may have, the transaction. See the outcome table.
  • Behaviour change: broadcastTx, on both clients, judges a 429's body before re-sending it. An already-known body resolves with the known txid (unknown_outcome without one), and a relayed transport failure is unknown_outcome, both at once; only a 429 whose body is neither is re-sent, as before. Such a 429 was re-sent while retries remained, so a later rate_limit, aborted or timeout could hide that the first attempt reached the node.