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;broadcastTxoutcomes reported as the reply shows them;- the
@noble/hashesminimum; - the
unknown_outcomemessage text.
@qbit-oss/protocol
createP2mrPsbt,precomputeSighashMidstates,validateP2mrSigningPlans,signP2mrPsbtandextractP2mrTransactionhandle transactions with any number of inputs and outputs, as dop2mrSighashandschnorrSignatureHashwithout cachedmidstates. They spread one call argument per input, output or witness item, so past about 120,000 of them they threw a bareRangeErroron Node's main thread. A 1-input, 131,000-output PSBT thatparsePsbtaccepts 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 sofinalizeP2mrPsbt,validateP2mrSigningPlansandsignP2mrPsbt, checks a PSBT's keys for duplicates by the idsparsePsbtuses, 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.combinePsbtsaccepts a map key over 64 bytes, such as a 65-byte script-sig key, that is aUint8Arraysubclass from another realm (vm, iframe, worker), for example aBuffer, or with@noble/hashesbefore 1.3.3 anyUint8Arrayfrom another realm. It threw a bareTypeErrorfrom@noble/hashes, which rejects such arrays.- Behaviour change: Requires
@noble/hashes~1.8.0(was^1.3.0) and imports itssha2entry point instead of the deprecatedsha256one. 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. Anoverridesorresolutionsentry still forces its version, and@noble/hasheshas nosha2entry point before 1.3.3, so this release fails to load with an override below 1.3.3.@noble/hashes1.8.0 declaresenginesnode: ^14.21.3 || >=16, where 1.3.0 declared none. - Behaviour change:
serializeP2mrDescriptor,p2mrRootFromDescriptor,p2mrAddressFromDescriptorandhasP2mrStandardSatisfactionthrowINVALID_DESCRIPTORfor 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 fromparseP2mrDescriptornever share nodes. - Behaviour change:
createP2mrPsbt,validateP2mrSigningPlansandsignP2mrPsbtthrowINVALID_PSBTfor a transaction without inputs or outputs, with two inputs spending the same outpoint, or with an input spending the null outpoint, which qbit Core'sCheckTransactionrejects (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 failedparsePsbt), 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.parsePsbtanddecodePsbtstill accept such a PSBT, as Core's PSBT decoder does, except one without inputs, whichparsePsbtalready refused. - Behaviour change:
parsePsbt,combinePsbtsanddecodePsbt(and sofinalizeP2mrPsbt,validateP2mrSigningPlansandsignP2mrPsbt) throwINVALID_PSBTfor a key with data after a type byte qbit Core requires alone, as Core does ("key is more than one byte type"): global0x00and0xfb; input0x00,0x01,0x03,0x04,0x05,0x07,0x08,0x13,0x17and0x18; output0x00,0x01,0x05and0x06. Such keys were kept, and the helpers match these types only as one-byte keys, so an input with an08 00record decoded as not finalized, andfinalizeP2mrPsbtappended a real0x08beside it, producing a PSBT Core rejects.
@qbit-oss/api
- New
@qbit-oss/api/testingentry point, for tests only:createMockEsplorareturns an offline mock of the qbit-electrs or mempool-qbit backend to pass to a client asfetchFn. It serves every routeEsploraClientandMempoolQbitClientcall from a declarative fixture, answers as the chosen backend does where they differ (an unknown txid's status is200 {"confirmed":false}on qbit-electrs and404on mempool-qbit), records each request, and injects per-route faults: an HTTP status withRetry-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'ssendrawtransactionverdict, 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:-22for hex Core cannot decode,-25for an unspendable output with a value (maxburnamount),-27for a transaction in a block while one of its outputs is unspent,-26for a brokenCheckTransactionrule,-25for a spend of an output that is not a coin and for a transaction a block spends from, which is in the chain,-26for a brokenCheckTxInputsrule (an immature coinbase output, input values out of range, outputs worth more than the inputs),-26for a conflict with a mempool transaction, which the mock never takes as a replacement,-26for a spend of a mempool transaction's unspendable output, which Core's script check refuses, and-25for a fee above the defaultmaxfeerate. 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 untilmine()confirms it; they follow qbit-electrs, which lists the mempool oldest first and by default indexes no provably unspendable output, such as anOP_RETURNone, 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, aRetry-AfterthatHeadersrefuses, 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. Atesting/package.jsonshim points resolvers that ignoreexportsat it: TypeScript'smoduleResolution: "node10"throughtypes, and Jest before 28 (given a test environment withResponse,HeadersandReadableStream) and Metro without package exports throughmain. Like@qbit-oss/api, it is built for ES2020, which webpack 4's parser does not support.@qbit-oss/apiitself resolves as before. See Testing. - Behaviour change: The
unknown_outcomeerror 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'skindand fields are unchanged. - Behaviour change: Requires
@noble/hashes~1.8.0(was^1.3.0) and imports itssha2entry point instead of the deprecatedsha256one, as@qbit-oss/protocoldoes, with the same effect on lockfiles and overrides.@noble/hashes1.8.0 declaresenginesnode: ^14.21.3 || >=16, where 1.3.0 declared none. - Behaviour change: New client option
maxResponseBytes, new error kindresponse_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 WHATWGReadableStreambody, or an async iterable one such as node-fetch's NodeReadable, which it destroys. A read over it fails withresponse_too_largeand is not retried, and de-duplicated reads share one request, one limit and one copy of the body. A submission's reply over it, a429included, isunknown_outcome, since the node may have accepted the transaction, and is not re-sent; a customfetchFn's ownresponse_too_largeon a submission isunknown_outcometoo. 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'sfetch, against theblob()'ssize, before its text is read (not witharrayBuffer(), which React Native 0.71 and earlier cannot read), else afterarrayBuffer(). A Response-like object with onlytext()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 JSONparseerror'sQbitApiError.unredactedCauseis now a copy ofJSON.parse'sSyntaxError, 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 onkindmeetsresponse_too_largein 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 identicalGETalready 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.EsploraClientstill reads the pages throughgetAddressMempoolTxsandgetAddressTxs, so a subclass's overrides of them still apply.MempoolQbitClient.getAddressTxscompares itsafterTxidcursor with the backend's txids case-insensitively, asEsploraClient.getAddressTxstakes it. An uppercase or mixed-case cursor never matched qbit-electrs's lowercase txids, so the call threwinvalid_cursor. A cursor that is not a string (from untyped input) still rejects asinvalid_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
httpQbitApiErrorfor abaseUrlwhose 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%XXUTF-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 everybroadcastTxended asunknown_outcome. - Behaviour change:
broadcastTx, on both clients, ends asunknown_outcome, without re-sending, when a429's body cannot be read in full: it fails, stalls pasttimeoutMs, passesmaxResponseBytes, or an abort or the deadline cuts it short. Such a429was taken as proof that nothing was processed: it was re-sent while retries remained, and then rejected asrate_limit, or asaborted/timeout. But the outcome table lets a429's body overrule its status (an already-known body resolves, a relayed transport failure isunknown_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 a429's body before re-sending it. An already-known body resolves with the known txid (unknown_outcomewithout one), and a relayed transport failure isunknown_outcome, both at once; only a429whose body is neither is re-sent, as before. Such a429was re-sent while retries remained, so a laterrate_limit,abortedortimeoutcould hide that the first attempt reached the node.