qbit-js 0.1.0
Release notes
This is the first release line, and nothing has been published before. Changes
are listed against the unpublished 0.0.0 development snapshot of both
packages, which early integrators consumed from git. Breaking marks a
change that can make existing caller code fail or behave differently.
@qbit-oss/protocol
- Breaking: PSBT and field-decoding results no longer alias caller memory.
Byte values are returned as fresh plain
Uint8Arraycopies, including when the input is a NodeBuffer. Callers that usedBuffer-only methods on returned fields must wrap them withBuffer.from(...). Serialized bytes are unchanged. - Breaking:
signP2mrPsbtkeeps every completed signature when a later plan fails. A new optional synchronousonSignedcallback fires after each signature is stored. Failures inside the signing loop throwP2mrPsbtSigningError(codeSIGNING_FAILED) carryingpsbt,signed,failed,phaseand the original error ascause. Signer exceptions no longer propagate raw.signandonSignedmust be synchronous: a returned promise fails that plan withINVALID_SIGNER.signedentries gainleafHash, andQbitProtocolErrorCodegainsSIGNING_FAILEDandINVALID_SIGNER. - Breaking: bech32 decoding rejects strings longer than 90 characters and any character outside printable ASCII before case folding, so look-alike spellings (for example U+212A KELVIN SIGN) no longer decode to a real address.
- Breaking: the P2MR signing path enforces sighash types as qbit Core does.
signP2mrPsbtandvalidateP2mrSigningPlansaccept onlySIGHASH_DEFAULTunlessallowNonDefaultSighash: trueis passed. They also enforce the input'sPSBT_IN_SIGHASH_TYPEand refuse mixed types on an input. A non-default type is written back toPSBT_IN_SIGHASH_TYPE.finalizeP2mrPsbtrejects a signature whose type differs from that field, or fromSIGHASH_DEFAULTwhen the field is absent.decodePsbtreportssighashType, and thesigncallback context (P2mrPsbtSignContext) gainshashType,txandspentOutputs. A PSBT signed with a non-default type by the0.0.0snapshot has noPSBT_IN_SIGHASH_TYPErecord and will not finalize until one is added. - Breaking: PSBT parsing rejects non-canonical CompactSize encodings, reads
PSBT key types as CompactSize, enforces Core's
MAX_SIZE(0x02000000), and checks the key-data shape of qbit P2MR proprietary keys in input maps. - Breaking: amounts must lie within
MoneyRange(0..QBIT_MAX_MONEY).version,locktime,vout,sequenceandcodeSeparatorPosmust be uint32, andkeyVersionmust be uint8. Out-of-range values throwINVALID_AMOUNTorINVALID_INTEGERinstead of being serialized and signed modulo 2^n.decodePsbtrejects out-of-range totals and outputs that exceed inputs.createP2mrPsbtrejectsnullforversion,locktimeandsequence. New exports:QBIT_MAX_MONEY,assertMoneyRangeandassertUint32. - Breaking:
verifyP2mrMessageProofrequires a fourthoptionsargument,{ expectedAddress }and/or{ expectedNetwork }(new exported typeVerifyP2mrMessageProofOptions), and binds the proof to it. The three-argument form is removed. A missing,null, non-object or emptyoptionsthrowsTypeErrorinstead of verifying. A proof that fails verification, or a malformed expectation such as an unknown network, still returnsfalse. Signet and Testnet4 share thetqHRP, soexpectedNetworkcannot tell them apart; passexpectedAddressto pin an exact address. - Breaking:
verifyP2mrMessageProofaccepts only the canonical lowercase spelling ofproof.address. A proof whoseaddressis uppercase returnsfalseand must be re-created;createP2mrMessageProofnow always emits the canonical lowercase address. - Breaking:
parsePsbtandextractP2mrTransactionbound what an untrusted PSBT can make them allocate, so a small hostile PSBT can no longer abort the process with a heap out-of-memory error. Both take{ maxBytes, maxObjects }.parsePsbtrejects input overmaxBytes(defaultPSBT_DEFAULT_MAX_BYTES, 16 MiB) before parsing it, andextractP2mrTransactionrefuses to build a larger transaction. One call decodes at mostmaxObjects(defaultPSBT_DEFAULT_MAX_OBJECTS, 131,072) map entries, transaction inputs and outputs, and final-witness items;maxObjectsdoes not scale withmaxBytes.finalizeP2mrPsbtandextractP2mrTransactionreject a final scriptwitness (0x08) with more than 1,003 items or over qbit Core's P2MR v1 stack limits (1,000 items, 16 KiB each, 128 KiB in total), andfinalizeP2mrPsbtvalidates an existing0x08instead of passing it through. These failures throwINVALID_PSBT. A limit that is not a positive safe integer, includingnull, throwsRangeError. At the defaults, the densest hostile PSBTs measured need at most 96 MB of V8 heap inparsePsbtand 144 MB withdecodePsbt.bytesToHexreturns the same strings, as flat strings in linear memory. New exports:PsbtLimitOptions,PSBT_DEFAULT_MAX_BYTESandPSBT_DEFAULT_MAX_OBJECTS. - Breaking:
qbitWalletMessageHash, and socreateP2mrMessageProof, throwsINVALID_BYTESfor a message containing a lone UTF-16 surrogate instead of hashing it with U+FFFD substituted, so two different strings never share a message hash.verifyP2mrMessageProofreturnsfalsefor such a message. - Breaking:
signP2mrPsbtandvalidateP2mrSigningPlanssign only leaves qbit Core signs: leaf version0xc0, and a script that is exactlypk(K)or amulti_awith a minimal threshold of at most 35, containing the signing key. Any other leaf throwsINVALID_PSBTbefore the signer runs. This includes a key pushed insideOP_PUSHDATAor non-minimally, extra opcodes orOP_CODESEPARATOR, other leaf versions, andmulti_awith k > 35. - Breaking: descriptor key paths and
[fingerprint/...]origins follow qbit Core'sParseKeyPathNum.parseP2mrDescriptorand every helper that takes a descriptor string throwINVALID_DESCRIPTORfor anHhardened marker or an index above 2147483647, with Core's error text (Key path value '<v>' is not a valid uint32,Key path value <n> is out of range). Hardened wildcards and multipath segments are still unsupported and now say so. - Breaking:
formatP2mrImportDescriptorsthrowsINVALID_DESCRIPTORfor these requests, which qbit Core v1.0.0 rejects or misapplies: a descriptor that isn't a string or has leading or trailing whitespace; arangethat isn't a[start, end]pair or covers more than 1,000,000 indices; a timestamp other than'now'or a non-negative safe integer; external and internal descriptors that are textually identical once checksums are removed; andpqc()keys that no single network decodes. It doesn't checkpqc()keys given as extended public keys, keys for a network other than the node's, or wallet state.docs/p2mr-descriptor-support.mdlists the supported forms. - Breaking: P2MR
multi_a/sortedmulti_athresholds above 35 are refused where they would make coins unspendable, as qbit Core does.multiALeafScriptthrows aRangeErrorfork > 35.p2mrRootFromDescriptorandp2mrAddressFromDescriptorthrowINVALID_DESCRIPTORfor anmr()descriptor whose every leaf needs more than 35 signatures. A tree with a spendable leaf still gets Core's address, and parsing still acceptskup to 999. New exports:P2MR_V1_MAX_MULTI_A_THRESHOLD(35) andhasP2mrStandardSatisfaction. - Breaking: descriptor objects passed to
serializeP2mrDescriptor,p2mrRootFromDescriptor,p2mrAddressFromDescriptorandhasP2mrStandardSatisfactionget the checksparseP2mrDescriptorapplies to strings, and throwINVALID_DESCRIPTORif they fail.- Some objects that were serialized or hashed as given are now rejected: a
threshold of 0, a 31-byte
rawmrroot, an unknowntype, a tree deeper than 128, or keys given asnumber[]. - Other bad input threw a
TypeError, aRangeErrororINVALID_BYTES, and now throwsINVALID_DESCRIPTOR. This coversnull, non-objects, wrong-length keys, and very deep or cyclic trees. - Node
Buffers and bytes from another realm are still accepted.
- Some objects that were serialized or hashed as given are now rejected: a
threshold of 0, a 31-byte
- Breaking:
finalizeP2mrPsbtkeeps the input fields qbit Core keeps on a finalized input: the non-witness UTXO (0x00), the witness UTXO, proprietary records other thanqbitsubtypes 1-3, and unknown key types. It drops the partial-signing fields Core drops (0x02-0x06,0x0a-0x0d,0x13-0x1dandqbitsubtypes 1-3) and an empty final scriptSig (0x07). Before, only the witness UTXO and the new final scriptwitness survived. An input to finalize that carries a non-empty final scriptSig now throwsINVALID_PSBT, because a P2MR spend's scriptSig must be empty and Core would treat that input as already final. - Breaking:
parseQbitUrireads the query like qbit Core's GUI parser.+is a literal plus, not a space.req-amount,req-labelandreq-messagefillamount,labelandmessage, and any otherreq-parameter throwsINVALID_URI.QBIT:in any case and one trailing/after the address are accepted. Where Core is lenient, it now throwsINVALID_URIinstead of guessing: a repeatedamount,labelormessage, a#anywhere, a case variant of those names or ofreq-(AMOUNT=5,REQ-expires=1,meſſage=x), a malformed percent-escape in any key or value, and an emptyamount=. A decodedlabelormessagecan hold control characters, so escape it before display. - Breaking:
parseQbitUriandformatQbitUrithrowINVALID_URIfor an amount aboveQBIT_MAX_MONEY(210,000,000 QBT), the cap Core's amount field enforces. Both used to accept any number of digits, so184467440737.09551617(2^64 + 1 base units) passed. - Breaking:
formatQbitUripercent-encodeslabelandmessagelike Core'sQUrl::toPercentEncoding: a space becomes%20instead of+, and! ' ( ) *are escaped. Text with a lone UTF-16 surrogate throwsINVALID_URIinstead of being replaced with U+FFFD. - Breaking:
decodePsbt, and through itvalidateP2mrSigningPlans,signP2mrPsbtandfinalizeP2mrPsbt, reject aPsbtobject thatparsePsbtcould never return: an input or output map count that differs from the unsigned transaction, an empty key, or a raw key repeated within a map. Each throwsINVALID_PSBTbefore any signature hash or signer call. Before, a second witness UTXO on an input changed the amount and fee shown while the signature committed to the first one.qbitproprietary input records with a subtype other than 1-3 are now kept undecoded, as Core keeps them, instead of throwingINVALID_PSBT. - Breaking:
finalizeP2mrPsbtbuilds the witness qbit Core'sFinalizePSBTbuilds, andvalidateP2mrSigningPlansandsignP2mrPsbtrefuse, before any signer call, a plan whose signature finalize would not use. Before, the preflight accepted inputs that finalize always rejected, or whose signature finalize then left out, so a stateful signer spent a key use for nothing.- The preflight runs finalize's own leaf selection over each input's
signatures plus the call's plans. When that completes a witness that
leaves out a plan's signature, the whole call throws
INVALID_PSBT(input N: finalizeP2mrPsbt would not use the signature planned for pubkey … and leaf hash …) and nothing is signed: another leaf finalize prefers is already complete (for example a dApp-supplied signature for the other leaf of a two-leaf wallet address), or the plan'smulti_aleaf reaches its threshold, with existing signatures or the call's other plans, on keys finalize places first. Finalize places from the last key: for a 1-of-2[K1, K2]leaf with K2 signed, a K1 plan is refused (as are plans for both keys in one call), but with K1 signed a K2 plan is accepted and finalize uses K2. Core signs only the leaf it finalizes, up to its threshold. - When no leaf completes, a call that plans signatures on two or more leaves
of one input throws
INVALID_PSBT(input N: this call plans signatures on L leaves and completes none; finalizeP2mrPsbt uses one leaf): a witness reveals one leaf, so at least one of those key uses is wasted. For example, a wallet planning every leaf that holds its key on a dApp PSBT listing two 2-of-2 leaves{U, K1}and{U, K2}. Core signs only its best partial leaf for the same reason (src/script/sign.cpp:743-756). Plans on one leaf that do not complete it are still accepted, for multi-partymulti_asigning, and so are separate calls for different leaves. - Neither the preflight nor finalize verifies signatures. An invalid signature already on the PSBT counts as a valid one: on a leaf finalize prefers, it gets the plan refused, but on the leaf finalize uses it makes finalize produce a transaction that does not verify.
- Finalize no longer requires exactly one leaf and one signature. It ignores
and drops leaf records that do not commit to the witness UTXO or are not
v1
pk/multi_aleaves, and signatures for other (pubkey, leaf hash) pairs. It finalizes amulti_aleaf once its threshold is met: one item per key, last key first, each a signature until the threshold is placed and empty otherwise. When several leaves are complete it uses the smallest witness, ties going to the bytewise-smallest script, and it uses a leaf's shortest committing control block. Single-key witnesses are unchanged. - Every P2MR signature on the input, used or not, must carry the input's
sighash type, or finalize throws
INVALID_SIGHASH, as Core does. decodePsbtlists a leaf carried as both0x1dandqbit0x02with the same control block once, and a signature carried as both0x19andqbit0x03once. Two different signatures for one (pubkey, leaf hash) now throwINVALID_PSBTindecodePsbtand every function that calls it, as Core rejects that PSBT.- The preflight refuses an input with a non-empty final scriptSig (
0x07). A control block whose control byte names another leaf version is skipped, as Core skips it, instead of throwingINVALID_SCRIPT; when no control block of the leaf commits, signing and finalizing throwINVALID_PSBT. - Finalize's
INVALID_PSBTmessages for an input it cannot finalize changed:finalize supports single-key P2MR spendsis replaced byno P2MR script signature to finalize,no P2MR script signature is for a key of a v1 pk or multi_a leaf script on the input,P2MR control block does not commit to the witness UTXO programorno committed P2MR leaf has enough signatures to finalize.
- The preflight runs finalize's own leaf selection over each input's
signatures plus the call's plans. When that completes a witness that
leaves out a plan's signature, the whole call throws
- Breaking: the byte inputs listed below must be a
Uint8Array; any other value now throwsINVALID_BYTES(<name> must be a Uint8Array), even when itslengthis right. A NodeBuffer, a subarray, a subclass and aUint8Arrayfrom another realm are still accepted; aUint16ArraywhoseSymbol.toStringTagwas redefined as'Uint8Array'is not. Code that passed a plainnumber[]or another integer typed array (Int8Array,Uint8ClampedArray,Uint16Array, ...) holding byte values got the correct bytes from most helpers and now throws, for examplep2mrScriptPubKey,encodeP2mrAddress,singleKeyLeafScript,p2mrBranchHash,encodeP2mrWitnessSignature,qbitDataSigPqcHash,createP2mrMessageProof'ssignature,createP2mrPsbt's txids, leaf scripts and output scripts,p2mrSighash's txids and scripts, and every key and value of a hand-builtPsbtgiven toserializePsbt: wrap such a value withUint8Array.from(...). A string, or an element outside 0-255, was encoded as different bytes: a 3680-character string signature gave a message proof whosesignaturewas 7360 hex digits,00for every character except a decimal digitd, which gave0d; a string script hashed as zeros; a stringscriptPubKeyinp2mrSighash'sspentOutputs, or a hex-string field of its cachedmidstates, gave a digest qbit Core never computes; a 3680-character string decoded as a 0-byte witness signature; a stringkeyDatagiven todecodeQbitProprietaryP2mrInputFielddecoded as an all-zero pubkey and leaf hash; and aUint16Arraywas truncated mod 256. This covers every fixed-length byte input (pubkeys, signatures, leaf hashes, merkle roots and siblings, outpoint txids, address programs, signing-keypubkeyandleafHash, and the five 32-bytemidstatesfields ofp2mrSighashandschnorrSignatureHash), the leaf script and control block ofcreateP2mrMessageProof,p2mrLeafHash,verifyControlBlockand theencode*LeafScriptFieldhelpers, every output and witness-UTXOscriptPubKeyofcreateP2mrPsbt(also withrestrictedOutputMode: false), thetx.outputsandspentOutputsscripts and theannexofp2mrSighash,schnorrSignatureHashandprecomputeSighashMidstates, the map keys and valuesserializePsbtwrites, thekeyDataofdecodeQbitProprietaryP2mrInputField, and the arguments ofoutputScriptToAddress,decodeP2mrWitnessSignatureanddecodeP2mrPsbtScriptSigField.parsePsbt,bytesToHex,decodeP2mrPsbtLeafScriptFieldand thevalueofdecodeQbitProprietaryP2mrInputField's leaf-script subtype are not checked yet. The error kind changes too: a byte input that is not aUint8Arraynow throwsINVALID_BYTESwhatever it threw before, whether aTypeErrororRangeErroror aQbitProtocolError(INVALID_SCRIPT,INVALID_PSBT,INVALID_BECH32) from a later check. For example,undefined,nullor aDataViewthrew aTypeErrororRangeError; anumber[]or string leaf hash inmerkleRootFromPathorverifyControlBlockthrew aTypeError; a string control block increateP2mrPsbtthrewINVALID_PSBT; a string control block or pubkey increateP2mrMessageProofthrewINVALID_SCRIPT; andencodeP2mrAddressthrewINVALID_BECH32for aUint16Arrayholding a value above 255. The exception is the sighashleafHash:p2mrSighashandschnorrSignatureHashthrowINVALID_SIGHASHfor one that is not a 32-byteUint8Array. - Breaking:
createP2mrPsbt,validateP2mrSigningPlansandsignP2mrPsbtenforce qbit Core's restricted-output consensus rule, which is on for mainnet, testnet4, signet and regtest (unless the node runs with-p2mronly=0). Every output must be P2MR (witness v2 with a 32-byte program), start withOP_RETURN, or be P2A (51024e73). Any other output, such as P2WPKH, P2TR, P2PKH, a bareOP_1or an empty script, throwsINVALID_SCRIPTbefore any signer call, even when no signing keys are given. Such a transaction used to be signed, finalized and extracted, spending a bounded signature, and then rejected at broadcast withtx-output-not-p2mr. Witness v3..v16 outputs are refused too: Core's consensus allows them on mainnet, testnet4 and signet, but they are non-standard, so no default node relays them, and anyone can spend them.OP_RETURNpush-only data and size, which are relay policy, are not checked. Pass the newrestrictedOutputMode: falseoption (oncreateP2mrPsbtandP2mrSigningOptions) only for a regtest node run with-p2mronly=0. New exports:isRestrictedOutputModeOutput, which mirrors Core's per-output check, andRestrictedOutputModeOptions. pqc()descriptor keys are base58-decoded with Core's 82-byte bound, so an oversized key fails at once withbase58 payload exceeds 82 bytesinstead of stalling the caller.combinePsbtsmerges in time linear in the total number of map entries, however many PSBTs are passed. A PSBT carrying many distinct keys in one map made it quadratic: 100,000 keys took about 50 s. Output and errors are unchanged. Its JSDoc now notes that rejecting conflicting values is stricter than qbit Core, which keeps the first value.serializePsbtserializes maps of any size. It spread every chunk into call arguments, so a map with more than about 62,000 entries threw a bareRangeErroron Node's main thread instead of serializing. That could lose a signaturesignP2mrPsbthad just produced. Output bytes are unchanged.QbitProtocolError.is(value)andP2mrPsbtSigningError.is(value)recognize those errors from any copy of the package, such as the CommonJS build when the error came from the ESM build, whereinstanceofis false. They check non-enumerableSymbol.for('qbit-js.QbitProtocolError')andSymbol.for('qbit-js.P2mrPsbtSigningError')brands on the class prototypes.instanceof, error instances and their serialization are unchanged.
@qbit-oss/api
- Breaking: the
EsploraClientconstructor validatestimeoutMs(integer1..2147483647),retries(integer0..10),retryBaseDelayMs(finite0..2147483647) andbaseUrl(absolute http(s), with no whitespace, control or format characters, backslash, credentials, query or fragment). An invalid value throwsQbitApiErrorwith kindhttp. Retry backoff is capped at 2147483647 ms. - Breaking:
broadcastTxnever re-sends a submission that may have reached the node. A timeout, a possibly-sent network error, a 5xx with no node verdict in its body, or a 2xx that does not echo the txid rejects with the new kindunknown_outcome, carryingtxid: checkgetTxStatus(error.txid)before resubmitting the same hex. An already-known reply (-27,txn-already-in-mempool, …) resolves with the txid. A coded node rejection (for example-25missing inputs or-26) is a definitehttpfailure whatever the HTTP status. With no txid available, a 2xx must be a bare 64-hex txid.broadcastTxaccepts{ txid }, malformed arguments throwTypeErrorbefore anything is sent, andtxidFromRawTxis a new export.@qbit-oss/apinow depends on@noble/hashes^1.3.0. - Breaking: cancellation and a per-call deadline. Every method on both
clients takes a final
{ signal, deadlineMs }argument, and the constructor takes a client-widesignal. One signal and deadline cover every request a call depends on: pagination, retries, backoff sleeps, body reads and the mempool-qbit/tx/pushfallback. An abort rejects with the new kindaborted(exhaustiveswitches overQbitApiErrorKindneed a case), and an expired deadline rejects withtimeout. Neither is retried, and a body still streaming is cancelled. ForbroadcastTx, an abort or deadline before the POST is sent isaborted/timeout. Once the POST may have been sent, it isunknown_outcomewith the txid, and the POST is never re-sent. An invaliddeadlineMsthrowsQbitApiErrorwith kindhttp.sleepFnnow receives the signal:(ms, signal?) => Promise<void>. - Breaking: a server-supplied
Retry-Afteris honoured only up to the newmaxRetryAfterMsoption (default 30000 ms). A longer wait now throws instead of sleeping: a read's 429/5xx surfaces at once asrate_limit/httpwithretryAfterMsset (de-duplicated GETs are released with it), and abroadcastTx429 rejects at once asrate_limitwithretryAfterMs, without re-sending. Previously any delay was slept on, and one above about 24.8 days fired at once. An overflowing delta-seconds value counts as too long.maxRetryAfterMsis validated like the other options: it must be an integer0..2147483647, and any other value, includingnull, throwsQbitApiErrorwith kindhttp. An HTTP status error (rate_limit,http,not_found,payload_too_large) whose reply has a parseableRetry-Afternow carriesretryAfterMs, including after retries run out. - Breaking:
timeoutMsbounds the whole response, headers and body (error bodies included), with one timer per attempt measured from its start; the call'ssignalanddeadlineMsbound the body read too. A body that stalls is cancelled and fails the attempt astimeout, carrying the reply'sstatus, and a read is retried with backoff (notRetry-After). Previously a stalled body hung the call, and a body that legitimately takes longer thantimeoutMsto arrive now times out. A 429/5xx reply that is retried is now downloaded under the timer before the retry.broadcastTxnever re-sends once a reply has arrived: a reply that stalls, cannot be read, or is cut short by an abort or deadline rejects withunknown_outcome(withstatusandtxid) after one POST. The exception is a429, which proves nothing was submitted: one whose body stalls or cannot be read is retried with backoff and rejects asrate_limitonce retries run out, and an abort or deadline during its body isaborted/timeout. For subclasses,request()resolves with a fully buffered response that keepsstatus,headers,urlandclone(). A customfetchFnno longer needs a globalResponse(without one,request()returns a buffered Response-like object withbody: null) and should honourinit.signalfor the whole response. - Breaking: requests are sent with
redirect: 'manual', and the client never follows a redirect. A3xxreply or a browser opaque redirect rejects asQbitApiErrorkindhttp, naming the target by origin only. Where the runtime follows a redirect anyway (React Native'sfetchdoes), a reply it reports asredirected, or whoseurlis not the requested URL, is refused the same way. A refused reply's body is discarded unread. Any difference in scheme, host, port, path or query counts, so a redirect to another path on the same backend is refused too; the case of the scheme and host, a default port and a trailing dot on the host do not count. On such a runtime the redirected request has already been sent, on Android with your customheaders: send an API key inAuthorization, or pass afetchFnthat honoursredirect: 'manual'. OnbroadcastTxa refused redirect of any kind rejects withunknown_outcomecarryingtxid(and the3xxstatus), with thehttprefusal ascause: thePOSTreached the backend, which may have accepted it, and a runtime that followed the redirect may have re-sent it. It is never re-sent (as aGETor otherwise), retried or sent to/tx/push.http://mempool.qbit.org, which answers301, now fails: use the https URL. AQbitApiErrorthrown by a customfetchFnnow propagates unchanged. - Breaking: a plain-
httpbaseUrlis rejected by the constructor unless its host is loopback (localhost,127.0.0.0/8,[::1]) or the new optionallowInsecureHttp: trueis set. Scheme and host are compared case-insensitively. The constructor'sbaseUrlchecks read the URL by hand, so they also run on React Native, whoseURLgetters throw before 0.80. - Breaking:
QbitApiError.messageand.urlno longer contain the base URL's path. They show the base as its origin followed by/..., then the endpoint path (https://host/.../blocks/tip/height), on every error the client creates,unknown_outcome,rate_limit,timeoutandabortedincluded. A base URL without a path is shown as its origin, and one whose host cannot be read reliably (an@after the host, or a host or port that is not plain) as.... Text the client quotes is redacted too, as far as it can be recognized: fetch's own error text innetworkandtimeoutmessages, and response text inmessageand inresponseBody(anunknown_outcome's included), which is no longer the raw preview. In it the base URL, its path (Express:Cannot GET /<token>/api/...) and each path segment of 8 or more characters are replaced, matching exactly and case-sensitively; a secret quoted any other way is not caught. Quoted text inmessagehas control characters, line separators and bidi controls escaped. The tip-height parse error now carriesurlandresponseBody.EsploraClientgains the protectederrorUrl(path), and itsbaseUrlis now protected. - Breaking:
causeis a redacted copy of the underlying error, with the samename(and class, for built-in errors), soerror.cause === originalno longer holds. The original is available only through the new staticQbitApiError.unredactedCause(error), andQbitApiErrorOptionsgainsunredactedCause. AQbitApiErrorthe client created is kept as thecauseas it is. Anabortederror'scauseis still the signal's reason, and aQbitApiErrorthrown by a customfetchFnstill propagates unchanged. - Breaking: fee tiers and address balances fail closed.
getFeeTiers()andfeeTiersFromEstimates()throw aparseQbitApiErrorwhen a fee rate is abovemaxFeeRate, when a rate is not finite or not above 0, or when a target is not a decimal block count from 1 to 1008. The defaultmaxFeeRateisDEFAULT_MAX_FEE_RATE_SAT_VB, 10,000 sat/vB, which is qbit Core's defaultmaxfeerate. Invalid entries used to be dropped. The default is a ceiling, not a sanity check: a backend can still return 9,999 sat/vB, so pass a lower{ maxFeeRate }tofeeTiersFromEstimates(), cap the total fee, or both.MempoolQbitClientrequires finitefastestFee,halfHourFeeandhourFeeabove 0.getAddressBalance()andaddressBalanceFromStats()throwparsefor malformed stats, for sums that are not non-negative safe integers (including JSON values above 2^53), for confirmed spent above funded, and for a negative total.funded_txo_sumandspent_txo_sumare lifetime totals, so an address that has received more than about 90M QBT over its life fails on every call; sumgetAddressUtxos()instead for such an address. On electrs a negative total can be transient right after a block, so retry on the next poll. New export:DEFAULT_MAX_FEE_RATE_SAT_VB. - Breaking:
getAddressHistoryon both clients throwsinvalid_cursorinstead of returning a history the backend cut short. It took an empty page as proof of completion, but qbit-electrs answers a cursor it cannot continue from with HTTP 200[](on a node that lags the one that served the previous page, behind a load balancer or mempool-qbit's failover; after a reorg between pages; on the combined route when the cursor is the last mempool row), and/txs/mempoollists at most 50 transactions. The call now reads the address stats before and after the scan, each in a request of its own, and requires exactlychain_stats.tx_countconfirmed andmempool_stats.tx_countmempool transactions. If the stats changed during the scan, it scans once more; if they change again, or the counts differ, it throwsinvalid_cursor. Each call makes two more requests (three with a rescan), and a backend without/address/:addressnow fails it. Some failures last as long as their cause:EsploraClientfails for an address with more than 50 mempool transactions, andMempoolQbitClientfor one whose mempool rows exactly fill pages of 50 while it has confirmed history (until the backend's combined route stops answering[]for a cursor that is the address's last mempool row). Stats without a non-negative safe-integertx_countthrowparse.maxPagesandmaxTransactionsnow cover the whole call, a rescan included; the stats reads andEsploraClient's/txs/mempoolfetch are not page fetches.MempoolQbitClient.getAddressTxs(address)reads the stats when an empty page follows a mempool row, and throwsinvalid_cursorinstead of returning[]when they count confirmed transactions it has not found. The protectedgetJsontakes an optionaldedupflag. - Breaking:
getTxHex(both clients) checks the reply before returning it, and throws aparseQbitApiErrorunless the body is non-empty, even-length hex that decodes to one complete transaction whose txid equals the requestedtxid(compared case-insensitively). The txid is the double SHA-256 of the witness-stripped serialization, as qbit Core computes it, so a wtxid never matches. Before, any 2xx body was returned as it was: mempool.qbit.org's empty200reply came back as'', and a backend could serve another transaction undetected. Spaces, tabs and line breaks around the hex are ignored, and the hex is returned in lowercase. The txid does not commit to witness data, so a reply with altered or stripped witnesses still passes. BothgetTxHexandtxidFromRawTxnow also refuse, asparse, the encodings qbit Core's decoder refuses even where they leave the txid unchanged: a count or length (witness section included) in a longer CompactSize form than its value needs or aboveMAX_SIZE(0x02000000), and a witness flag with every witness stack empty;broadcastTxstill sends such hex as it is, without a derived txid. QbitApiError.is(value)recognizes aQbitApiErrorfrom any copy of the package, such as the CommonJS build when the error came from the ESM build, whereinstanceofis false. It checks a non-enumerableSymbol.for('qbit-js.QbitApiError')brand on the class prototype.instanceof, error instances and their serialization are unchanged.- Breaking: a
QbitApiErrora customfetchFnthrows is recognized by that brand, so one from the other build (an ESM-loaded fetch wrapper used by a client from the CommonJS build, say) propagates as it is, as one from the same build already did. Before, it was re-wrapped asnetwork: arate_limitlost its kind andretryAfterMs, and a read was retried. Such an error is judged by its public fields only, since the copy that created it keeps its original cause and response body to itself: on a submission, itsnetworkerror never counts as proof that nothing was sent (it becomesunknown_outcome), and its response body never triggersMempoolQbitClient's/tx/pushfallback. - The
credentialsoption is typed with the new exportQbitFetchCredentials('omit' | 'same-origin' | 'include') instead of the DOM'sRequestCredentials, so the declarations compile without the DOM lib, for example withlib: ["ES2022"]andtypes: ["node"](before:Cannot find name 'RequestCredentials'). They still need globalfetchandResponsetypes, which@types/nodedeclares from 18.18.7 in 18.x, from 20.8.4 in 20.x, and from 22 on. The accepted values are unchanged.
Packaging
- The packages are published as
@qbit-oss/protocoland@qbit-oss/api.repository,bugsandhomepagepoint atQbit-Org/qbit-js. - Both packages publish ESM type declarations (
lib/index.d.mts) through nestedimport/requireexport conditions, expose./package.json, ship their LICENSE, and build onnpm pack/npm publish(prepack).pnpm pack:checkasserts both tarballs' contents and that each package's LICENSE matches the root one. - Both packages are published only from
Qbit-Org/qbit-js, by a workflow run on the release tag, with npm provenance. After each package's first version, which a short-lived token publishes, the run authenticates through npm trusted publishing. Release candidates go to thecandidatedist-tag and final releases tolatest. pnpm consumer:checktype-checks a consumer against both packed packages' declarations (lib/index.d.tsandlib/index.d.mts) withskipLibCheck: false, as a Node-only project (no DOM lib) and as a browser project (no@types/node), and loads the CommonJS and ESM builds side by side to check the error-class brands, including that a client passes afetchFn's error from the other build through. CI runs it.
Development
- pnpm 11.28.6, pinned by
packageManagerand read by CI, plus a dev toolchain refresh (vitest 5, tsup 8, TypeScript 5.9) and Dependabot. Developing in this repo needs Node.js^22.13.0 || ^24.0.0 || >=26.0.0. The published packages'enginesare unchanged. - Release process:
RELEASING.md, this changelog, and CI onrelease/**branches. - The Core-derived P2MR fixtures are reproducible and checked in CI.
p2mr-psbt.json's finalized input now carries the witness-stack count that qbit Core andextractP2mrTransactionrequire, andsource.methodis gone from both fixtures. The generators check the qbit checkout's commit andorigin, import only agit archiveexport of that commit, and need a Python withtarfile'sdatafilter (3.12+, or 3.9.17+, 3.10.12+ or 3.11.4+).pnpm fixtures:checkregenerates both fixtures fromQbit-Org/qbitv1.0.0 and fails unless they match byte for byte. CI runs it, which needs network access to github.com. - The fresh-consumer install smoke installs both tarballs and also imports
@qbit-oss/apithrough ESM and CJS.