Changelog¶
Every change of a release, in full: what changed, why, and what it cost. RELEASE_NOTES.md has the release notes, which say what a user has to act on; this file is the record behind them, and is where a claim in those notes can be checked.
The record starts here, and nothing is reconstructed for the years of
work before it: a changelog written backwards from a git log is a guess
at what somebody would have noticed, and there is no way to check the
guess. This paragraph used to date that boundary to a v0.1.0 tag,
deleted on 2026-08-23 (#553, btclib-org/.github#105); the boundary is
where this file was written rather than where anything was tagged.
Unreleased¶
test.yml says which checks main actually requires (closes #749)¶
The comment above
test-passedsaid nothing required a status check, and four do. It namedmain-integrityfor signatures and linear history and called the classic protection “empty”, citing btclib-org/.github#88. Read back today, classic protection carriesLint and type-check,test: every job passed,Build the documentationandRegtest against Bitcoin Core, and none of the three rulesets holds arequired_status_checksrule. #88 is the issue that records that state moving, so the comment cited its own evidence for the thing that evidence had stopped saying.It is the comment a reader consults to learn whether anything in this file refuses a landing, so anything keyed on it – what a job’s place in
test-passed’sneeds:buys, how a report-only cell differs from a gating one – started from a false premise.REPOSITORY.md’s Required checks on main stays the one place the four are listed, and the comment points there instead of repeating them, which is what kept the two statements able to disagree.Four other places named the wrong mechanism-holder. The push trigger’s “the name main’s ruleset can require”, this job’s own “what a ruleset matches”, and “which a ruleset counts as satisfied” are classic branch protection in this tree, not a ruleset. Each now names the mechanism, or is written to hold of either.
CLAUDE.md’s union bullet gains the seam model (closes #753)¶
The unit of the damage is the seam, not the rebase. One blank line eaten per seam, a seam being where the two sides’ added blocks abut in the fused output; a blank line internal to one side’s own addition survives.
origin/iss-547-changelog-boundaryis the case that isolates it – two insertions in one merge, the line lost at the second and not the first. Eighteen seams across two sessions in one day, eighteen eaten.The
merge-treesentence was read as licence to skip the command, which it is not. It was, and remains, true about conflict detection: exit code and silence are not evidence of a safe rebase. What it left a reader to infer is that merge-tree is no use here. It writes the tree the rebase will produce, and the damage is in that blob with a line number. The case that shows both halves at once is a merge that conflicts: exit1, stage-1/2/3 entries for seven source paths,CHANGELOG.mdpresent only asAuto-merging, tree written anyway, flush heading readable at line 2853.The hunk header does not predict a seam, recorded so it is not re-invented. Anchor equality is necessary and not sufficient, and a first-hunk
grepreads the wrong hunk on a branch making two insertions.A no-seam case has to be shown to be a case at all. Two branches whose entry already landed produce a fused blob byte-identical to the base: nothing arrives, no seam is possible, and every check passes including the rebuild. Correct measurement, different question. The guard is one
cmpagainst the base with a real arrival as control.The general form is what outranks the rest: a check that can only report the absence of damage passes on an input that cannot carry the damage, silently and correctly. Three instances in one day – the null case, the cached badge render whose
failingis worthless and whosepassingis sound, and the-n automutation already listed.And what says whether a check needs a guard is whether its input can go empty without anyone choosing it. A walk can, through a refactor nobody thinks of as touching the test; a module literal cannot, a count set to zero being deliberate. So the rule carries a condition rather than demanding ceremony everywhere – a sibling tree whose equivalent layer is driven by a literal needs no such guard.
A reconstruction is a control only once the block’s own boundaries have been read, the splice otherwise duplicating a blank on one side and dropping one on the other, which reads as a two-line seam that is not there.
The suite gains the property layer the fuzzer presupposes (closes #742)¶
tests/property_test.pystates, over generated octets, the property every harness infuzz/already encodes as acontextlib.suppress: a declared entry point either returns or raisesBTClibException, and nothing else. A property test answers does this hold over the domain I described; a fuzzer answers what is in the domain I did not describe, and the second presupposes the first – so what the weeklyfuzzrun reported was bounded by a description the suite did not carry.hypothesis, section 7’s named shape, and not hand-rolled properties. The domain is unconstrained octets, which is
binary()and nothing more, so hand-rolling buys no fidelity and loses the shrink; this tree also had no hand-rolled property machinery to build on. Executed rather than reasoned about: with aRuntimeErrorplanted inframe_message_bytes, the layer went red on that entry point alone and reported the minimaldata=b'\x00\x00\x00', where a seeded generator reports whichever blob it drew. The mutation was reverted and the revert proved by the marker’s absence.The specs come from the harnesses’ own
ENTRY_POINTS, throughfuzz_corpus_test.py’s existing walk rather than a second one: two walks over one fact are two things that can disagree, and a harness added for a new parser now gets a property test by being added.What the walk skips is structural, not a name list. A
fuzz.-prefixed spec is one_resolveloads out offuzz/by path, and today’s names a dispatch through a wholeNoderather than a parse – section 7’s own carve-out being that a subject which is not a parser does not owe the layer.An empty walk is guarded.
parametrizeover an empty sequence collects nothing and reports no failure, so the layer would be green having stated nothing; a test asserts the walk finds something.Profiles are registered once in
tests/conftest.py:defaultat 500 examples,thoroughat 2000, selected byHYPOTHESIS_PROFILE,deadline=Nonebecause a per-example limit is a timing flake on the slowest cell of the matrix and this suite runs one onwindows-latest. 500 is affordable measured rather than assumed – the whole file runs in 1.31s against a suite of about ninety.tests/README.mdrecords the choice where section 7 says a reader meets it.
links.yml asks lychee for the fragment too (issue btclib-org/.github#583)¶
.github/workflows/links.ymlpasses--include-fragments. A link into a heading is then checked as an anchor and not only as a page, where the forge serves the page and drops a fragment it cannot resolve, so a heading renamed in the tree a link here points into is red in this run rather than nowhere. The check reads the page already fetched for the link and adds no request, and no fragment this tree links to fails it today.What the flag reaches here is bounded by the token the step already holds. Every anchor this tree carries into another repository is the bare
github.com/btclib-org/.github#<heading>shape, and on a failed link of that shape lychee falls back to the repositories API and takes the repository’s existence as the answer, so those anchors are not what this buys; btclib-org/.github#630 is where that is weighed.
The free-threaded cell stops reddening the run (closes #747)¶
test.yml’sfree-threadedjob branches on its own environment sync instead of failing at it. A workflow run’s conclusion aggregates every job in it,needs:or not, so a report-only cell that cannot install concluded every push tomainfailurewhiletest: every job passedwas green. #723 closed that as a gate, by taking the cell out oftest-passed’sneeds:(#727), and left the run conclusion exactly where it was.The saturation had already cost something. #737’s red
windows-latestwent unreported through eight consecutive pushes: with the run red for a known and accepted reason, a second red reason changed nothing a reader could see.Not
continue-on-erroron the job, which would buy the green by hiding every outcome including the two worth hearing. Nocp314twheel is now a::notice::and a green job; an installed wheel is interrogated before the suite runs. #746 measured why that second step is needed: rocksdict builds for3.14tunmodified but declares a bare#[pymodule], which PyO3 documents asgil_used = truefor 0.23 through 0.27, so importing it hands the GIL back – andNode.worker_poolreadssys._is_gil_enabled()at first use, after that import. A cell that simply ran the suite would report a free-threaded pass measured entirely on the process-pool arm.The two states that are news fail the job and colour the run: a wheel that installs but gives the GIL back, and a suite that genuinely fails free-threaded.
A false sentence in the file’s header is corrected. It tied
free-threadedrejoiningneeds:to #723 closing; #723 closed by doing the opposite, and the row still does not install.
The per-test timeout is remeasured (closes #737)¶
pyproject.toml’stimeoutis 300, from 120. The old number was the same ratio the comment still argues – nearly three times the worst test – against a suite whose worst wastests/functional/p2p/download_test.py::test_downloadat 42.09s. That suite has since growntests/unit/chainstate/utxo_index_test.py::test_rev_patchand its::test_long_initbeside it, 75.30s and 63.78s onubuntu-latestunder coverage and 86.20s for the second of them onwindows-latestwithout it, where the first exceeded 120 outright. What moved is the suite and not the runner:::test_rev_patchstages a 20000-block chain and unwinds all 20000 of itsRevBlocks in 34s serial on a ten-core machine, at a 143 MiB peak.The
windows-latestcell had been red on every push tomainsince #728 for exactly that, reporting it asworker 'gw3' crashed while running ...::test_rev_patchrather than as a timeout. pytest-timeout pickssignalwhereSIGALRMexists andthreadwhere it does not, and thethreadhandler ends the process withos._exit; under-n autothat is a worker that stopped answering, which xdist reports as a crash with no traceback and nothing naming the bound. Reproduced away from Windows by giving thethreadmethod a test that sleeps past it, which produces the same two lines. The comment besidetimeoutsays so, so that a crash on that image is read as a suspected timeout first.One bound rather than a marker on the slow tests: three tests now sit within a factor of two of it, so marking two of them would leave the third to be the next to fail.
The three chain scripts guard their module body (closes #579)¶
scripts/chains/mainnet.py,testnet.pyandsignet.pybuild and start theirNodeunderif __name__ == "__main__":.Node.worker_poolis amultiprocessing.Pool, and every start method other thanforkre-imports__main__in each worker (multiprocessing/spawn.py’s ownimport_main_path), so an unguarded script built a secondNodeon the same data directory in every pool worker once block download warmed the pool.
A rejection test asserts which rule refused a block (closes #587)¶
Node.last_rejected_blockpairs the hashupdate_chain’s trial loop was on with the exception it raised, once itsexceptcatches one – alongsidefailed_hash(src/btclib_node/main.py), which it already tracked and only logged.update_chaincatchesExceptiongenerically, so a rejection test asserting onlyhash not in active_chainwas satisfied by any raise anywhere in the per-block gate: a new rule ranked ahead of an older one there could retire the older one’s own end-to-end test silently, with the suite green at its 100% coverage floor. Measured on #568/#571’s own branch, where two tests built their bad block with a coinbase that, before that branch, committed to no height, and after it were refused for BIP34 before either ever reached the rule it was written for.The exception is captured rather than matched from a log line:
update_chainalready logs it, but only behind a fixed message and the logging module’s own formatting, neither of which a test should have to parse to ask what actually raised.tests/unit/main_test.py’srejected_because(node, block, phrase)asserts both halves: thatblockis the node’s own last rejection, and thatphrase– a substring of the exception’s own message, not an exact match, since the wording is btclib’s or this tree’s own to change rather than an interface either promises to keep – is why.tests/unit/chainstate/filter_index_test.pyimports it the way it already importsconnectandspend. Applied to every rejection test in both files that used to assert only the outcome.
A block is checked against subsidy and BIP34 (closes #568, closes #571)¶
Chaincarriessubsidy_halving_intervalandbip34_height, one per network – read from Core’ssrc/kernel/chainparams.cppat bitcoin/bitcoin@204256c73f: mainnet, testnet3 and signet halve every 210000 blocks and regtest halves every 150; mainnet’s coinbase must commit to its height from block 227931, testnet3’s from 21111, and regtest’s and signet’s both from block 1.A block whose coinbase pays more than subsidy plus fees does not connect (closes #568). Core’s
bad-cb-amount(ConnectBlock,src/validation.cpp:2619-2621, at bitcoin/bitcoin@204256c73f):nFees + GetBlockSubsidy(...)is the ceiling, andChain.subsidyis this tree’s ownGetBlockSubsidy.Block.assert_valid_contextualis called where a block connects, with the height it connects at (closes #571), so a coinbase that does not commit to it (BIP34’sbad-cb-height) does not connect either. Core’sContextualCheckBlock(src/validation.cpp:4170-4176, at bitcoin/bitcoin@204256c73f).
The user agent names the project, and its version (closes #580)¶
/Btclib/becomes/btclib:<version>/(closes #580), which is BIP14’s/Name:Version/– the shape Core’s ownFormatSubVersionbuilds (src/clientversion.cpp:65-70) and the one a crawler reporting what the network is made of parses. The name is the project’s own spelling, lowercase, and not the distribution’sbtclib-node: btclib is what this is a node over.The version is read from the installed distribution, through
importlib.metadata, rather than written here a second time. No gate in this tree reads the wire, so a literal is the one spelling of the version that nothing would catch drifting, andRELEASING.md’s Which version string is which already tracks four of them. A checkout ofmaintherefore announces the cycle it is open on and what pip installs announces its release day.A tree that was never installed raises rather than announcing a placeholder: a user agent is a claim, and one saying
unknownwhere the version belongs is worse than a node that says why it will not start.The test reads the framed octets, not the constant: what #580 reported is what a real peer received –
getpeerinfoon thebitcoinda node of this tree was connected to answered/Btclib/– and a constant asserted against itself answers for nothing in between. Verified by mutation: puttingb"/Btclib/"back fails the new test and nothing else.
CLAUDE.md’s union bullet trusts the endpoint, not the cache (closes #565)¶
CLAUDE.md’s
merge=unionbullet told a session to “rebase and look” past GitHub’smergeable: CONFLICTING, reading a local rebase’s silence as proof the merge was clean (closes #565). A driver built never to conflict cannot report one whether the merge it produced is right or wrong, so the silence was never evidence of anything.The rewrite does not swap one unmeasured trust for another (closes #565):
gh pr view --json mergeableis an asynchronous, cached read that can still answerUNKNOWNon a pull request alreadyMERGED, so aCONFLICTINGseen there is not itself confirmed real. The merge the endpoint actually attempts (gh api -X PUT .../merge) is the one genuine three-way merge in the pair, and its refusal is the true report. The bullet now points atRELEASING.md’s step 3 reconstruction as the check that tells a safe rebase from a fused one, and namesgit merge-tree --write-treeas applying the same driver rather than being a dry run of the question.
RELEASING.md treats the simple API as the index’s state (closes #545)¶
RELEASING.mdnamed the JSON API’s staleness as a quirk of theprovenancefield alone (closes #545). It now states the general rule: the simple API is the index’s own state and the JSON API is a cache of it, disagreeing with the simple API on more than which fields it fills in.The If something goes wrong section’s first branch, on whether anything was uploaded, now answers that from the simple API rather than from which job a run reports as having failed —
publish-pypiis not idempotent, so retagging over a version that in fact landed fails a second time on top of the first.The
github-releaserecovery script’s own digest check read the JSON API for a version the workflow had just published, the exact situation the staleness reaches; it now reads the digest off the simple API instead.
tests/README.md declares the suite’s split (closes #530, #531)¶
tests/README.mdnow carries the split’s reason, and the package__init__.pydocstrings point at it instead of restating it (closes #531): section 7 of the organization standard asks for the split betweentests/unit/,tests/functional/andtests/integration/to be declared intests/README.md, and it lived instead in the package docstrings, each naming its counterpart rather than pointing at one place.tests/integration/conftest.py’s docstrings no longer name a single consumer (closes #530): they namedbitcoind_test.pyas the module the fixtures below are for, where every module undertests/integration/asks for them; both now say “this package’s tests” instead of one module that stopped being the only one.
dependabot.yml argues from what deps-latest.yml does (closes #558)¶
The uv ecosystem’s comment stops arguing from a missing
latestsentinel (closes #558):deps-latest.ymlis in the tree and resolves the same floorpyproject.tomldeclares a day before Dependabot’s own Thursday run, so a Thursday pull request bumping a dependency is not the first run that tries the bump – it is only the first whose own required checks a merge waits on, the earlier report not being one of them.The github-actions ecosystem keeps the old argument, now in its own sentence (closes #558): nothing resolves an action version the way
deps-latest.ymlresolves a dependency, so that pull request is still the first run that tries one, and weekly is still the trade against a month of unread drift.
The Read the Docs bullet stops waiting on a met condition (closes #559)¶
REPOSITORY.md’s Read the Docs bullet gave “this tree does not publish – the day it does” as the reason nothing is connected (closes #559), andv2026.8.27is that day. It now says what the connection is actually waiting on: an action on Read the Docs’ own side, importing the project under the organization’s account, which nogh apicall in that file can take or read back. Issue #563 tracks it.A reason naming a condition already met is worse than a stale record (closes #559). A stale record is wrong about the past; this was wrong about what happens next, and the next reader takes the section as saying the work is due and goes looking for the blocker. It is also the same defect #556 fixed in the sibling bullet of that same section two hours earlier and did not carry across – #553’s own finding, arriving from a decision I made myself rather than from somebody else’s.
The
homepagebullet beside it loses a reason the release falsified too (closes #559): “there is no published site for it to point at”, where https://pypi.org/project/btclib-node/ has been one since the release. Whether to set it stays a decision of its own –btclib’s ownhomepageishttps://btclib.orgrather than its documentation site, so it is not something the Read the Docs connection settles on its way past.The bullet carries the badge command and its control (closes #559):
btclib-nodeanswersunknownwherebtclibanswerspassing, which is the difference between a project that does not exist and one that does. Both were run against the live service.
RELEASING.md’s two steps that a release disproved (closes #554, closes #561)¶
The bill-of-materials step describes the document the script writes (closes #554). It said the document names “one component per dependency the wheel’s metadata declares —
btclib[secp256k1], and whatever it in turn resolves to on the interpreter that built the release”.btclib_node-2026.8.27.cdx.jsonnames one component,pkg:pypi/btclib, with no version and nothing transitive. The script is right and the prose was wrong:.github/scripts/generate_sbom.py’s own docstring says a resolved version “would be a claim the wheel does not make”.It also says what the document therefore does not cover (closes #554): a consumer matching vulnerabilities against it gets the direct dependency and nothing below. That is a property rather than a defect, and it is only a property if the step saying to read it says so. The
git+https://check, which is why the step exists, holds and stays the point of the paragraph — verified by running the step’s own command against the published release rather than by reading it.A verification step whose expected output does not match a correct run is the failure mode here (closes #554), and it is worse than having no step: a reader who follows it finds one component where the text led them to expect a tree, and has to decide whether the release is broken, the script is, or the sentence is. The one that leaves no trace on the artifact is the right answer and the hardest to reach just after publishing something.
Step 3 gives the check that tells a safe rebase from a fused one (closes #561), keeping the prohibition as the default rather than replacing it. The check is the redo itself, done in a scratch file: rebuild the file from
git show origin/main:<file>plus this release’s own edits, andcmpit againstgit show HEAD:<file>. Identical bytes prove the rebase produced what the redo would have, and a difference hands back the file that should have been there.v2026.8.27was rebased when #551 landed in front of the tag, and this is what licensed it.Two checks that look like that one are named as not being it (closes #561), both measured rather than reasoned about. Comparing the added and removed lines of the diff before and after the rebase is necessary and not sufficient: it catches a fusion that ate a line and passes a pure misordering — the same entry below the newly landed one instead of above it, every line intact — because the
+lines are the same sequence wherever they land. This entry’s first draft prescribed exactly that check, and the counterexample was built to test it rather than to illustrate it. Andgit merge-tree --write-treeis not a check at all:merge-ortreads.gitattributesfrom the trees it merges, applies theuniondriver, and writes the fused blob with exit0.The reconstruction is owed on the hand redo the rule prescribes (closes #561), which the rule did not say. Retitling a heading the landed change also opened fuses it the same way, and having typed it yourself says nothing about that — a prohibition standing in for a check it could have specified does not cover the path it sends the reader down.
The crons and the badges follow the reordered calendar (closes #520)¶
Every
cron:here names the instant section 10’s calendar gives its workflow at this repository’s own minute (issue btclib-org/.github#480): that calendar orders its rows by what they ask about, and the day and the hour are its to state, so no comment here restates them.deps-latestis the one schedule this leaves where it is, and.github/dependabot.ymlis not touched: section 10 ties Dependabot’s own run to it.A schedule comment names the cadence rather than a weekday (issue btclib-org/.github#480): a comment naming the day is one the next move through the calendar falsifies while it stays grammatical, and the day is not this tree’s to state in the first place.
mutation.ymlloses its reasoning about the emptiest day with it: the calendar groups by family instead.README.md’s badge block is section 2’s three groups (closes #520): what the software is, whether it works, and what the OpenSSF makes of it – the gates inside the second in the order a commit meets them, then the sentinels in the calendar’s order. The Scorecard badge opens the third group rather than sitting among the sentinels, which is whatscorecardbeing the calendar’s last row buys.The block’s own comment points at section 2 rather than restating it (closes #520): the rule the block is kept to lives in the organization standard, so a copy of it here is a second thing to keep true, and this one had stopped being true.
github/v/release, the licence andwheelare in the block (closes btclib-org/.github#496):v2026.8.27is on the index and on the forge, so each of the three renders a measurement. The licence badge isLICENSEread throughimg.shields.io/github/license, which is the spelling that section asks for.
The tree’s prose says what the forge holds (closes #553, closes #555)¶
Four files no longer say nothing has been published (closes #555):
v2026.8.27is on PyPI and on the forge, andREADME.md,CONTRIBUTING.md,RELEASING.mdandREPOSITORY.mdeach said it was not. Two of them printed the pre-release answer beside a live command —README.md’s badge comment, andCONTRIBUTING.md’scurl … # 404, which answers200— and those are the two that cost a reader something, since running the command is what the file asked for and disagreeing with it is what the reader gets.README.mdsays how to install the package (closes #555), which it could not truthfully do until today and which a published project’s README not saying is a gap rather than a matter of taste.Eleven sites stop arguing from a
v0.1.0tag that was deleted on 2026-08-23 (closes #553). The tag and its release were removed on the maintainer’s decision closing btclib-org/.github#105 — a lightweight tag is a ref with no object of its own, so there is nothing on it to sign, and a repository publishing nothing had no release the tag was the record of;portanode’sv2026.01.27went the same way the same day, both release bodies captured into that issue first. This tree’s files were never carried along, and went on describing the tag for four days.What made it hard to see is that
git taganswers locally (closes #553): the tag is still in every clone fetched before that day, pointing at the real 2023 commitd0ac7646, so six files agreeing with each other and with a localgit tagis what a reader checking them found.gh api …/git/ref/tags/v0.1.0answers404; btclib-org/.github#105’s own measurement four days earlier recorded it answeringcommit, which is the pair that dates the deletion.The seven historical
CHANGELOG.mdentries are left as they stand (closes #553). #504’s entry gives “a version this repository had already tagged” as a reason for retiring0.1.0, and that was true when it landed — the tag was deleted after it. A changelog edited to agree with what happened afterwards stops being a record; the correction is here, in the entry for the fix.release.yml’s--exclude 'v0.1.0'is kept and its comment made checkable (closes #553): the flag has been inert since the deletion, and removing it would change the behaviour of a path that runs twice a year and cannot be rehearsed against a second tag, in exchange for nothing — fromv2026.8.27on that tag is the nearest one reachable from any later release, so av0.1.0re-cut tomorrow could not be resolved as “previous” either way.RELEASING.md’sgh workflow run404 paragraph is a record rather than a prediction (closes #555). It said the 404 “bites once, on the first release after it is written”; it bit once, onv2026.8.27, and inverted the order exactly as written — the TestPyPI rehearsal asked for before the merge ran after it, still before the tag.RELEASING.mdsays whatv2026.8.27did and did not exercise (closes #555):public-apiresolved no previous tag, skipped its own comparison and reported success in eight steps, so the release that has run is the one that says least about that job. The first one to exercise it is the next.
The downloads badge links to the plural pepy path (closes #578)¶
README.md’s downloads badge now links tohttps://pepy.tech/projects/btclib-node, the plural path section 2 of the organization standard fixes; the singularhttps://pepy.tech/project/btclib-nodeit linked to before answers a redirect rather than the page itself. The badge image URL,https://static.pepy.tech/badge/btclib-node, is unchanged: section 2 already fixes that spelling.
A coin knows its own height, coinbase bit and maturity (closes #569)¶
The UTXO record is a
Coin, not a bareTxOut(src/btclib_node/block_db/__init__.py): the height of the block whose transaction created the output, and whether that transaction was the block’s own coinbase, alongside the output itself. Matches Core’s ownCoin(src/coins.h, at bitcoin/bitcoin@204256c73f) – a varint packing(height << 1) | coinbaseahead of the output – except for Core’s ownTxOutCompression, a space optimisation this class does not reproduce:KeyValueStoreis measured write-dominated, not read-dominated (btclib-org/btclib-node#586), which argues for the varint staying tight and not for a second optimisation on top of it.RevBlock.to_add(same file) carriesCoins rather thanTxOuts for the same reason Core’s ownCTxUndodoes: a reorg that restores a spent output has to bring back the height and coinbase bit it was created with, not the height of whichever block the restore runs at.A spend of a coinbase output not yet
COINBASE_MATURITYblocks deep does not connect, and does not enter the mempool either (interpreter.check_coinbase_maturity, called from bothmain._validate_blockandmain.verify_mempool_acceptance). Core’sbad-txns-premature-spend-of-coinbase(Consensus::CheckTxInputs,src/consensus/tx_verify.cpp:185-186, same commit), checked at both of Core’s own call sites (ConnectBlockandAcceptToMemoryPoolWorker) because both reach the same rule against a different spend height.COINBASE_MATURITY(src/btclib_node/constants.py) is Core’s own bare100, not part ofChainand not relaxed for regtest, matching Core’s ownconsensus.h.KeyValueStorerefuses a store from before this schema version existed, rather than misreading it (src/btclib_node/db.py):PRAGMA user_versionis stamped on a fresh store and checked on every open, kept out of thekvtable itself so a version marker never sits inside the key orderBlockIndex.init_from_dbdepends on. A version-0 store already holding a row is what a datadir from before this existed looks like, told apart from a genuinely fresh one, which starts at the same0.tests/__init__.py’sgenerate_random_chainno longer embeds a spend younger thanCOINBASE_MATURITY: every block up to that depth carries its own coinbase alone, and every block past it spends the oldest output the chain has made spendable –chain[0]’s own coinbase the first time, and that spend’s own output after, since neither is ever a coinbase again. Every test built on a chain shorter than that, or spending its own tip rather than its root, is adjusted to a shape this rule actually accepts.
Node.__init__ refuses inside a re-imported __main__ (closes #589)¶
Node()raisesReimportedMainProcessErrorwheremultiprocessingre-imported__main__to build it –current_process().nameis not"MainProcess"and the active start method is notfork– rather than leaving that solely to the module-bodyif __name__ == "__main__":guardscripts/chains/*.pynow carries (#579): a caller that forgets the guard used to build a secondNodeon the same data directory in every worker its ownworker_poolspawned, silently, with no exception from eithermultiprocessingor this tree.allow_reimported_main=Trueopts a caller out of that refusal: the two calls it reads cannot tell an unguarded module top level from a supervisor deliberately building aNodeinside its own pool worker, so the distinction is the caller’s to state rather than__init__’s to guess.tests/unit/scripts_test.pyparsesscripts/chains/*.pywithastand asserts everyNode(...)call sits lexically inside the__main__guard’s own lines, resolved through whatever nameNodeis actually bound to rather than the literal string"Node"– an aliased import still reads as compliant, and a wrapped call is still found. Closes the gapscripts/sitting outsidetestpathswould otherwise leave: nothing else in the suite would catch a future flattening of one of the three shipped scripts.Lexically, and nothing beyond it, which refuses
def main(): Node(...)called from the guard – correct code, refused. A version of this test did follow a barename()call into the function it named, and four review rounds each found a different way to make that answer guarded for aNode(...)that really does run unguarded on the re-import: twodefs sharing a name, then adefnested in the guard shadowing a module-level one, then alambda, aclassand animport ... asdoing the same without being adefat all. Deciding which definition a name reaches is Python’s own binding semantics andastdoes not model them;symtabledoes not answer it either, reporting a plaindefas assigned exactly as it reports one shadowed by alambda. The five shapes are kept as tests of the limit, since a resolver reintroduced without them would pass its own tests.
Two platform sentinels, and neither gates a merge (closes #528, issue #430)¶
.github/workflows/os-ubuntu.ymlruns the suite onubuntu-latestandubuntu-24.04-arm, weekly (closes #528), which is where aarch64 Linux is exercised at all. It is not a sweep over pure Python:pyproject.tomldepends onbtclib[secp256k1], so a compiled libsecp256k1 is resolved and imported at run time, and an aarch64 runner selects a different published wheel of it from the one the gate’s cell installs.Its matrix is the image, and the interpreter axis is absent by decision – which the header states, so that a single column does not read as a matrix somebody left half written.
requires-pythonis>=3.14because an application takes the newest interpreter its dependencies allow, which is what issue #507 settled, so that axis has one value here and that value is the merge gate’s own cell. The sibling repositories that carry this workflow sweep interpreter against image, and copying that shape would mean giving the axis versions this package does not claim..github/workflows/os-windows.ymlruns the same suite onwindows-latest, weekly, and is expected to fail (issue #430).loop.add_reader, whichsrc/btclib_node/p2p/manager.pyandsrc/btclib_node/rpc/manager.pyboth call, belongs to the selector event loop rather than to the Proactor loop Windows takes by default;signal.SIGTSTP, whichinstall_signal_handlerspasses tosignal.signal, does not exist there. Issue #429 records that pair and calls it a lower bound. A sentinel reports where a gate refuses, so it may land red, and its runs produce the list that prices the gate cell issue #430 stays open for.No Windows classifier lands with it (issue #430): a classifier is a claim made to an index, and the claim would be that a pull request is checked there, which is the gate cell rather than the sentinel.
pyproject.toml’s comment besideOperating System :: POSIXsays that, and names the two calls the suite reaches.Both cells pass
--no-cov,test.yml’s coverage job being where the floor is measured and gated: the floor is a claim about one interpreter on one image, and a cell held to it would report a platform’s own finding under the name of the coverage number.tests/interpreters_test.pygains both files, its list of the CI files that name an interpreter being written out rather than derived.The two badges sit after
os-macos’s inREADME.md, where section 2’s fixed order puts them, that order over the sentinels being section 10’s calendar.test.yml’s header names the three platform sentinels beside it, rewritten rather than extended: the paragraph’s own subject was that a Windows sentinel would be the support claim rather than its check.REPOSITORY.md’s concurrent-job ceiling names the platform axis by the sentinels that carry it rather than byos-macos.ymlalone.CONTRIBUTING.md’s walk of the workflows that only report names both besideos-macos.yml.release.yml’s header saidos-macos.ymlwas this tree’s only platform sentinel and that neither of these two was here, andRELEASING.mdnamed the one where there are now three: both say the set rather than the member, andrelease.yml’s pointer attest.ymlresolves again, that header having said what each of the two would ask and now saying what each of the three does.
BIP30, finality and BIP68 gate a connecting block (closes #570, closes #572)¶
A block that creates an output already unspent elsewhere on the chain does not connect (
UtxoIndex.add_block’s own BIP30 check, closes #570) – Core’sbad-txns-BIP30(ConnectBlock,src/validation.cpp:2401-2431, at bitcoin/bitcoin@204256c73f), CVE-2012-1909’s shape: without it, a second block sharing an already-mined, still-unspent txid silently overwrote the first block’s own coin, and a reorg away from the second deleted an output the first block’s own branch still carried. Checked over every transaction the block carries, coinbase included, before either ofadd_block’s own two loops stages a write – against the state exactly as it stood before this block, matching Core’s own separate pre-pass.Chain.bip30_exceptionscarries the two 2010 mainnet blocks Core’s ownIsBIP30Repeatexempts (src/validation.cpp:6218-6222, same commit), empty on every other network. Once BIP34 binds (#571), a block’s own coinbase commits to its own real height, which two different heights can never share – the property that makes a new violation of this kind unreachable, and the reason the two 2010 blocks are the only ones this check will ever have to carve out.A transaction whose
lock_timehas not been reached does not connect, and does not enter the mempool either (interpreter.check_final_transactions/is_final_tx, closes #572) – Core’sbad-txns-nonfinal(ContextualCheckBlock,src/validation.cpp:4158-4166, at bitcoin/bitcoin@204256c73f). Checked against the previous block’s median time past once BIP113 binds – the same heightChain.flagsalready turnsCHECKSEQUENCEVERIFYon at, Core deploying both as one soft fork – and against the block’s own timestamp before that; the mempool path checks unconditionally against the active chain tip’s own median time past, matching Core’s ownCheckFinalTxAtTip.A non-coinbase transaction whose BIP68 relative lock is not yet satisfied does not connect, and does not enter the mempool either (
interpreter.check_sequence_locks) – Core’sSequenceLocks/CalculateSequenceLocks/EvaluateSequenceLocks(src/consensus/tx_verify.cpp:45-115, same commit), over each input’s ownCoin.height(#569). A relative lock binds a transaction of version 2 or above only, and an input carrying the disable bit is skipped, both matching BIP68.
Read the Docs is recorded, and README.md carries its badge (closes #563)¶
README.mdcarries the Read the Docs badge (closes #563), after thedocsworkflow badge and before the sentinels, which is the position section 2 ofbtclib-org/.github’sREADME.mdfixes for it. It answerspassingwhere a fabricated project name answersunknown, so it reports a state rather than an absence, which is what the badge block’s own rule asks of a badge.REPOSITORY.mdrecords the project in a section of its own (closes #563), “Read the Docs, which is btclib-node.readthedocs.io”, which is the shape the bullet it replaces said this file would take: whatlatestandstablefollow, read back from the project API, theread-the-docs-communityGitHub App the connection runs through rather than a webhook of this repository’s own, and the 404 on/en/v2026.8.27/beside the 200s on/en/latest/and/en/stable/. Activating a release tag needs an automation rule on Read the Docs’ own side, which #596 tracks, so the section records the tag’s absence rather than a pull request fixing it.“What is not configured, and why” keeps the Pages half (closes #563) of the bullet that described the two together,
gh api repos/btclib-org/btclib-node/pagesstill answering404. Thehomepagebullet beside it is untouched: it is a decision of its own, and btclib-org/.github#533 is where the rule for it is being settled.The comments that pointed at that bullet point at the section (closes #563):
.readthedocs.yamlon which versions run the build it configures,pyproject.tomlbeside[project.urls] documentation, whose link it said would 404 until a project was connected,release.ymlon the job it does not have – bitcoin-core-rpc’sdocumented, which polls/en/<tag>/until that tag is served and here would wait on a version no rule activates – anddocs.yml, which said read the docs builds this tree once a project is connected there.
CLAUDE.md records what this batch cost a round each to learn (closes #600)¶
The docs build is named as the third gate, with what it alone catches: a closing backtick followed by a bare letter is not a valid RST end-string,
sphinx-build -Wfails on it, andpytestand the lint gate both stay green. Latent until it lands on a rendered docstring, autodoc never reading an underscore-prefixed function’s own.caplogsees nothing this tree’s logger emits,Node.loggerbeing built withoutlogging.getLogger()and so having no parent to propagate to. A test asserting againstcaplog.recordspasses by asserting nothing, which is why the entry says how to observe that logger instead.A peer session may hold the same tree, which the tracker does not say and
git worktree listdoes: two sessions produced two branches for one issue within two minutes, each having checked first.The
uniondriver’s silence hides two defects that do not travel together – the entry placed below the one already there, which the organization’s bottom-append rule wants, and the eaten blank line, which is damage under either convention. Measured three times; the reconstruction is owed because the silence does not say which happened.len(active_chain)is the height a block extending the chain would connect at, genesis sitting at index 0, which is what a mempool check wants and what an off-by-one inverify_mempool_acceptancehad wrong until #569.
Config(pruned=True) raises rather than being silently ignored (closes #574)¶
Config.__init__refusespruned=Truewith the newPruningNotImplementedError(src/btclib_node/exceptions.py) instead of storing it and doing nothing with it:BlockDBnever deletes a file, so the previous behaviour wrote every block of mainnet todata_dirfor a caller who had asked it not to.pruned=False, the default, still constructs.The parameter and the field stay –
prunedis public API, and nothing here is removed for it – with a field comment onConfig.prunedexplaining the refusal, in place of the two comments that used to citeprunedas an ordinary, freely settable example alongsidedebugand the other booleans.btclib-org/btclib-node#601 is where pruning itself gets built. The exception’s own docstring cites what Bitcoin Core does instead – drops
NODE_NETWORKfrom its advertised services, keeps onlyNODE_NETWORK_LIMITED, and deletes a block file once it is more thanMIN_BLOCKS_TO_KEEP(288 blocks) behind the tip.
Connection.stop closes the socket on the loop’s own thread (closes #518)¶
stop, called from a thread that is notself.loop’s, no longer closesself.clientitself. It schedules a new method,_close, onto the loop throughcall_soon_threadsafe, and_closeremoves any reader and any writer registered for the socket’s fd before closing it.BaseSelectorEventLoop._sock_read_done(asyncio/selector_events.py) callsremove_readeroncerun’s ownsock_recvfuture completes or is cancelled, and_remove_readertakes_selector.modifyrather thanunregisterwhere a writer is still registered on the same fd –Connection._send’s ownsock_sendall, whichasync_sendreaches through_deliver, for a peer not draining its send queue.modifyre-registers, and registering an fd already closed raisesOSError: Bad file descriptorfromKqueueSelector’s owncontrol()call;unregisteralone swallows exactly that error, which is why the traceback only ever surfaced with a writer sharing the descriptor. Removing both before closing means neither callback finds anything left to remove by the time it runs.Reproduced without the fix at 30 of 30 rounds, on both the GIL build and
3.14t, with a writer registered on the same descriptor as a pendingsock_recv; 0 of 30 with no writer registered.stopis called fromhandle_p2p,handle_p2p_handshake,callbacks.pongand every other caller that drops a peer for cause, all onNode’s own thread rather thanP2pManager’s loop, which is what put every one of those calls on the losing side of the race.tests/unit/p2p/connection_test.pygains a test driving this from a real second thread running the loop –P2pManager’s own shape – withstopcalled from the thread running the test, neither one the loop’s.
The peer-versus-node verdict is in the line it logs (closes #526)¶
handle_p2p_handshake,handle_p2p,resume_cfiltersandresume_getdata(src/btclib_node/p2p/main.py) each log which of the two an exception was decided to be, rather than the same"Exception occurred"regardless: the command and the connection that raised, and whether that connection was discouraged for it. The traceback under the old line answered what raised; it never answered whose fault the node decided that was, and that verdict – theisinstance(e, BTClibException)check the line sits beside – existed only in memory.conn_id, not the peer’s own address, is what each line carries: Core keys the same judgment the same way, itsPeerManagerImpl::Misbehaving(src/net_processing.cpp, at bitcoin/bitcoin@05e49b342f) loggingpeer=%dand nothing else, andCNode::LogPeerappending the address only underfLogIPs, which is off by default. An address on a line every exception writes, whether or not the peer is at fault, is a privacy cost for nothing.The handshake’s own
Connected toline now carries that id beside the address (p2p/callbacks.py), which is what makes an id-keyed line resolvable back to a peer at all. Core pairs them the other way round and only on request; this tree logged the address here unconditionally already, so withholding the id bought no privacy and only cost the correlation – an operator could read that connection 42 was discouraged and never learn which peer 42 was.The other six sites this issue named but did not file are unchanged: none of them weighs a peer’s own fault against this node’s, so none has a verdict to lose. Named by file rather than by line, the issue’s own numbers having been read at its creation-time commit and moved twice since –
grep -rn "Exception occurred" src/btclib_node/re-derives them, where a number here would go stale again.The paragraph above each of the four sites, arguing the
BTClibExceptionsplit (#283, #515), is left as it was: it argues why the split is drawn, which the log line does not restate – the line reports which way the split fell on one run, not why the code draws it there at all.Read back from a real
Loggerwriting to a file, not fromcaplog:Node.loggeris built directly fromlogging.Loggerrather than throughlogging.getLogger, so nothing it emits reaches the root logger pytest’s capture handler sits on, and an assertion againstcaplog.recordswould pass on every one of the four having logged nothing at all.
The union driver’s two defects are one (closes #610)¶
CLAUDE.md’smerge=unionbullet said the driver “places the arriving entry below the one already there” and called that the wanted result under the organization’s bottom-append rule. Hours after that landed, on this same tree, an arriving entry went above the one already there.The two are not independent, and the blank line is the cause. A branch that lost its blank line to an earlier rebase carries that loss as context into the next one, and the driver orders on it. Isolated on the real case: restoring that one line in the branch’s own commit, changing nothing else, and rebasing onto the same base moves the entry back below. Seven rebases in one day fit it – six carried the blank line and landed below, the seventh had lost it and landed above.
So an inverted order is a report rather than bad luck: it says this branch was rebased twice and lost its blank line the first time. That is worth more than “arbitrary”, which was the first correction and was wrong.
The check has to be a full comparison, not a nothing-was-removed assertion. A misordering repositions rather than deletes, so it passes that weaker test –
RELEASING.md’s step 3 already argues the same insufficiency. And the reconstruction must normalize the arriving block rather than copy it: an expected file built by copying inherits the missing blank line and matches the damage it exists to catch, which is how two branches in this batch reached review with the markdown gate red.
A getdata answer’s notfound, and its inv, are paced (closes #529)¶
advance_getdata(p2p/callbacks.py) paces a miss the same way it already paces a block or a transaction it does hold, checked before every item rather than once the whole request is served. A miss used to cost nothing againstMAX_GETDATA_INFLIGHT_BYTES, so agetdatanaming mostly transactions this node no longer held committed its wholenotfoundon top of whatever blocks in the same request had already queued, pastMAX_QUEUED_SEND_BYTESand into the drop – for a peer whose request had done nothing wrong._send_due_announcements(download.py) paces a transaction announcement’sinvagainst the same bound, checked before everyMAX_INV_SZchunk, leaving what a pass could not commit queued for its own next call rather than sending as many chunks as the mempool had entries to announce, unpaced, in one pass.Neither change alters what a well-behaved peer receives, only when a large answer’s tail arrives; a node that used to drop a connection in either shape now paces it instead, which is not a compatibility break.
dial refuses every non-IP network, not every non-IPv4 one (closes #616)¶
get_peer_info’s comment (rpc/callbacks.py) saiddialrefuses “everything but IPv4”, arguing from that why a BIP155 id no member names can reachPeerDBbut never aConnection.dial(p2p/address.py) refuses every id outside_IP_NETWORKS = (IPV4, IPV6)and then picksAF_INET6for the second, opening a real socket on it – an IPv6 peer is dialled, not refused. Both halves are already exercised:manager_test.py’s IPv6 accept inbound, andaddress_test.py’s owntest_a_v6_peer_that_is_listening_is_connected_tooutbound, which binds a realAF_INET6listener and asserts the familydialopened.The conclusion the comment draws was never wrong, only its reason: the guard is the two IP networks rather than IPv4 alone, so the cast below it still stands. What is corrected is a claim about the code beside it, which is what a reader checks the cast against.
A connection’s id is logged beside its address at creation (closes #611)¶
P2pManager.create_connectionnow logs the id it mints beside the address the connection was accepted from or dialled to, before any wire message is parsed.callbacks.verack’s own pairing (#526) is the last statementverackexecutes, so a handshake exception raised before it – a malformedversion, the most common one, among them – left the idhandle_p2p_handshake’s own except block logs unpaired with any address anywhere in the tree.create_connectionis the one point every path into a connection shares, dialled or accepted, ahead of that exception.Unconditional on the address, like
verack’s own line and for the same reason, argued there rather than twice here: Core’s analogous site,CNode’s own constructor (src/net.cpp, at bitcoin/bitcoin@05e49b342f), gates the address onfLogIPs, off by default.verack’s own line is kept, not removed: it marks the handshake completing, this one marks the connection existing, and the two moments are what an operator needs told apart to place where a connection that disappeared between them actually got to.info, matchingverack’s own line:async_connectand_maybe_dial_more_peersonly reachcreate_connectiononcedialhas already returned a live socket, so this runs once per connection actually made, never once per dial attempt.
The 100% floor’s main.py loss did not reproduce (closes #617)¶
Seven whole-suite runs on this repository’s ten-core machine — five at the default
-n auto, two at an oversubscribed-n 20— withCOVERAGE_DEBUG=dataio,combineandCOVERAGE_DEBUG_FILEpointed outside the rootdir, each combined exactly one data file per worker plus the master’s own, losing none of them. The candidate mechanism named in #617 —xdist/pytest-cov’s own parallel-data combine dropping a worker’s.coverage.*file — was not caught in the act, on the same coverage 7.15.4 / pytest-cov 7.1.0 / pytest-xdist 3.8.0 pins the tree already carried at #617’s own sha.The silence such a drop would leave is real, whether or not it happened here:
pytest-cov’sDistMasternever passesmessages=Trueto thecoverage.Coverage()it drives itscombine()through, so a dropped or duplicate-skipped file changes nothing a stock run prints.CLAUDE.md’s Non-obvious facts carries the discriminator against ISS 372 and ISS 319, and what to do if the shape recurs. Nothing in the coverage configuration is changed by this: a change made without a demonstrated mechanism behind it would be believed and could hide the next occurrence.
bitcoin-core-rpc is declared where it is used (closes #606)¶
tests/functional/rpc/chain_test.pyandtx_test.pyimportbitcoin_core_rpcat module top, andpyproject.tomlsaid nothing about it: the package arrived transitively, as one of btclib’s own dependencies. The reasoning for those top-level imports lived only in this file, in the entry that made them – so if btclib ever dropped or gated that dependency, the suite would break with nothing in this repository’s own metadata to say why, and no lockfile diff here to point at.>=2026.8.20in thetestgroup, which is the versionuv.lockalready pinned. The floor is deliberately the resolved version and not a newer one: this declares what the tree relies on rather than deciding to move onto something. The re-lock adds only the dependency edges, no package entry having had to move.tests/integration/conftest.pystill hand-rolls that client instead of importing it, which is a separate change (issue #607) and is not closed here.
tests/integration/conftest.py imports bitcoin_core_rpc (closes #607)¶
Bitcoind.rpcwasrequests.postwithauth=(user, password), the cookie parsed by hand with.partition(":"), its path assembled by hand asdatadir / "regtest" / ".cookie", and its errors raised asRuntimeError–bitcoin_core_rpc’s ownBitcoinCoreRpcClient,cookie_authandcookie_path_from_chainrewritten against a real bitcoind, that library’s home ground.Bitcoind.rpcis now a thin wrapper overBitcoinCoreRpcClient.call, built with the explicit-url constructor rather thanfrom_chain: the fixture starts bitcoind on a portget_random_portdrew, not Core’s regtest default.The comment defending the copy – “the cookie is read again on every call rather than cached at construction” – described the library’s own behaviour and not something the copy did differently:
cookie_authre-reads the file at every call already, so importing the client keeps that property rather than losing it._wait_for_rpcwaited on a bareexcept Exception; it now waits onFetchError,bitcoin_core_rpc’s own base for a cookie not yet written (CookieNotFoundError) or an rpc socket not yet listening.Exercised against a real
bitcoindv31.1.0 withBTCLIB_NODE_INTEGRATION=1(tests/integration/bitcoind_test.py,backpressure_test.pyandreorg_test.py, 3 passed), since the ordinary gate only collects this package and skips its tests without that switch and a daemon.
[project.urls] homepage names this tree’s own documentation (closes btclib-org/.github#533)¶
[project.urls] homepagereadshttps://btclib-node.readthedocs.io, matchingdocumentation. A releasing tree provides documentation, and its home is that documentation rather thanbtclib.org, a sibling’s project page.documentationstays: an index showing the two fields as one link is cheaper than the field tools read for that purpose specifically.REPOSITORY.md’s Read the Docs section records.homepage, read back from the endpoint rather than frompyproject.toml’s own copy of it. The bullet in “What is not configured, and why” that called the field unset is gone with it: the reason it gave for leaving the field unset – no published site to point at – was already answered by https://pypi.org/project/btclib-node/, and the field it argued was a decision of its own now agrees withdocumentation.
CONTRIBUTING.md’s reporting walk names scorecard.yml too (closes #595)¶
.github/workflows/scorecard.ymlis now among the workflows What gates a merge, and what only reports walks. It carries neither apull_requesttrigger norworkflow_dispatch, unlike every other workflow that section names, so it is also the one reporting workflow with no way to ask about a branch at all.
MAX_PENDING_CFILTERS_HEIGHTS no longer quotes a stale sentence (closes #537)¶
The comment beside
MAX_PENDING_CFILTERS_HEIGHTSquoted a sentenceconnection.py’sMAX_QUEUED_SEND_BYTESno longer carries – rewritten away whenget_cfiltersgained its own pacing bound (#442), the same commit that added the quotation here. The paragraph now argues the two-full-requests room on its own terms instead of citing a sibling constant’s reasoning; the bound’s own value is unchanged.
REPOSITORY.md’s must-not-require list names scorecard.yml (closes #626)¶
scorecard.ymljoinslinks.yml,os-macos.yml,bootstrap-dns.ymlandclaude-review.ymlin the list of workflows that must not become required checks. It carries neither apull_requesttrigger norworkflow_dispatch, so unlike the other four a required check on it could never be satisfied by any pull request at all.
The rpc listener’s default port is Core’s own, not p2p_port + 1 (closes #605)¶
Chaincarries its ownrpc_portnow, one belowporton every leaf — 8332/18332/38332/18443 for mainnet/testnet/signet/regtest, Core’s ownCreateBaseChainParams(src/chainparamsbase.cpp, at bitcoin/bitcoin@05e49b342f).Config.__init__derived it fromchain.port + 1instead, which happens to be Core’s own Tor incoming-connection port for each of these four chains rather than its rpc one, so a client left on Core’s own rpc default never found this node.tests/unit/config_test.py::test_default_rpc_port_is_cores_ownchecks the default for all four chains againstbitcoin_core_rpc.rpc_port_from_chain, read independently of this node’s ownConfig— every functional rpc test passes an explicitrpc_portof its own, so none of them exercised the default this issue was about.
An I/O fault trying a block is not the block’s own fault (closes #620)¶
update_chain’s trial loop tells a content failure from a storage one by exception type, not by which call raised it._CONTENT_FAILURE(main.py) isBTClibValueError,InvalidBlockInputErrorandPrevoutCountMismatchError– what_validate_blockandutxo_index.add_block’s own BIP30/double-spend checks raise to refuse a candidate’s own content. Everything else the trial’sto_addloop raises – aKeyValueStoreread or write failing insideutxo_index.add_blockorfilter_index.add_connected_block, orChainstateInconsistencyError– rolls the trial back and then propagates out ofupdate_chain, rather than invalidating the block it happened to land on.Core keeps the same split inside
ConnectBlock(src/validation.cpp, at bitcoin/bitcoin@b91d983f66): an ordinaryCheckBlockfailure is rejected, aBLOCK_MUTATEDone isFatalError.A propagated exception reaches
Node._step_chain, whose own existing catch stops the main loop and closes every database, the same path a failure out of_blocks_to_add,_rev_blocks_to_removeor_finalize_forkalready took.
The UTXO cache survives across connected blocks (closes #586)¶
UtxoIndex.updated_utxo_setandremoved_utxosnow stage several connected blocks’ own changes rather than one, up toUtxoIndex._FLUSH_BOUND(500,000 entries), andmain._finalize_forkwrites them only onceUtxoIndex.should_flushsays that bound is reached. Before,_finalize_forkflushed on every connected block – one sqlite read and one write per input for the length of the chain, the larger half of a sync and the only one of the two this node cannot make faster by adding cores, blocks connecting one at a time on this store’s single writer.BlockIndex.stage_statusandFilterIndex’s ownpendingare held back the same way, andChainstate.flushwrites all three – the block a status names, the UTXO set it was validated against, and the filter built from it – into the one SQLite transaction this store already gives a caller, so it never advances one of the three past another.Chainstate.closeflushes before closing, so a clean stop loses nothing staged.What an unclean stop costs is decided rather than left implicit. The store reopens holding exactly the state of its last flush, and a block validated since is simply offered to
update_chainagain –check_transactionsincluded – the same way a block arriving for the first time is, rather than through a replay path of its own.db.py’s docstring argues this against Bitcoin Core’s ownFlushStateToDisk/ReplayBlocks, which writes a separate block-tree LevelDB and a separate coins LevelDB in sequence and reconciles a crash landing between the two; this store’s one shared, one-batch write has no such gap to reconcile.UtxoIndex.rollbackandFilterIndex.rollbackundo only the mutations a failed trial itself made, through a small per-trial undo log, rather than wiping every staged change: a trial rolled back against an earlier, already-succeeded trial’s own still-unflushed state would otherwise discard that state too.UtxoIndex.get_coinreads a coin through the staged dicts before the store, andmain.verify_mempool_acceptancecalls it rather than readingUtxoIndex.dbdirectly, for the same reason the staging exists at all: a coin several blocks’ own worth of staging created is real beforefinalizeever writes it out.BlockIndex.set_status– and soinvalidate, its own caller – routes throughpendingrather than writing straight through wherependingalready holds the hash being set.update_chainsetsfailed_hashto a block acrossutxo_index.add_block,_validate_block,block_db.add_rev_blockandfilter_index.add_connected_blockalike, so a fault in either of the last two – an I/O failure, nothing to do with the block’s own content – invalidates a block exactly as a real validation failure would, and can reach a hash a chain-tip flip-flop already staged inpending(disconnected, then offered again). A write-through there used to be undone by the very nextfinalize, which still held the stale pending entry and wrote it back over the invalidation, with no crash needed.tests/unit/chainstate/block_index_test.py’stest_invalidate_after_stage_status_is_not_undone_by_a_later_finalizereproduces it against the write-through and passes against the fix.utxo_index.py’s own entries bound is now argued with a measured figure: 500,000(serialized OutPoint, Coin)pairs held in a plain dict, measured withtracemalloc, come to about 229 MB – the same order as Core’s own 450 MiB-dbcachedefault the comment already cites for contrast.UtxoIndex.apply_rev_blockunmarks a restored prevout fromremoved_utxos, not only puts it back intoupdated_utxo_set. A reorg restoring a prevout that was durable when the block being undone spent it left the outpoint in both:add_blockhad staged that spend with_mark_removed, andapply_rev_block’s own restore never cleared it. A later, legitimate spend of the same output then hitadd_block’s own double-spend guard and the block carrying it was invalidated for good, stranding the node on a lighter fork. This was unreachable before staging crossed trial boundaries – the old, per-trialfinalizewiped both dicts, and the stale flag along with them, before a second trial could ever see it.The same stale flag also hid a genuine BIP30 duplicate of the restored output.
_bip30_violationreadsremoved_utxosbeforeupdated_utxo_setor the store, so a block recreating an outpoint that a reorg had just made unspent again connected instead of being refusedbad-txns-BIP30– CVE-2012-1909’s own shape, independently of the double-spend-guard consequence above;_unmark_removedrunning unconditionally fixes both at once.UtxoIndex.add_block’s own two creation loops now unmark a recreated outpoint fromremoved_utxosbefore staging it, the same orderapply_rev_block’s own restore already used. Spending a durable output stages it intoremoved_utxosalone; a later block, still uncommitted, recreating that exact outpoint’s own txid then staged the recreation intoupdated_utxo_setwhileremoved_utxosstill carried it, leaving the outpoint in both at once. A subsequentapply_rev_blockundoing that recreation raisedChainstateInconsistencyError("output already removed")on a coin that was legitimately staged and unspent. Unreachable through the node’s own production caller, which validates a candidate block’s BIP34 commitment before this staging could ever be reached a second time for the same coinbase, and a non-coinbase duplicate txid is already a plain double spend caught elsewhere – so this is a private-method invariant made to hold by construction rather than a reachable fault.
CLAUDE.md’s Following Bitcoin Core says always, not reasonable (closes #638)¶
The section now reads “matches Core’s behaviour, always”, replacing “wherever that is possible and reasonable”, and names the permitted axis of variation as the language – Python-native, fluent or efficient in Python where Core’s own line is shaped by C++ – rather than a design weighed against Core’s. #605’s rpc-port-from-p2p-port argument and #631/#636’s framing of a corrupt-store abort as a justified divergence are what “reasonable” had licensed.
src/btclib_node/db.py’s example is relabelled rather than removed. Stdlibsqlite3over Core’s vendored LevelDB is a stdlib-versus-compiled-dependency choice, on the permitted axis rather than an exception the rule carves out for this tree’s own constraints.A clarification says mimicry is of Core’s behaviour end to end, not of a local
catch: Core’sCDBWrapper::Readanswers “absent” on a deserialize failure only because LevelDB’s own checksum has already made real corruption fatal before that line runs, so a tree without that checksum which raises instead is reproducing Core’s behaviour rather than departing from it.A clarification says a capability Core has that this tree lacks, for a library reason rather than a decision, is a gap to close and not a constraint to design around – btclib-org/btclib-node#637 and btclib-org/btclib-node#641’s missing checksummed VFS is the live case.
src/btclib_node/download.py’s own docstring carried the same “possible and reasonable” wording and is corrected to match.
The suites drive the seams bitcoin-core-rpc ships (closes #632, closes #633)¶
The test-group floor moves to
bitcoin-core-rpc>=2026.8.29, forcall_raw,call_batchandSessionTransport.tests/functional/rpc/*.py’s hand-rolledrequests.postenvelopes are built throughBitcoinCoreRpcClient.call_rawwherever it can express the case – the protocol marker as an argument, the reply read exactly as far ascall_rawreads before interpreting it. What stays ontests/__init__.py’s ownposthelper: a request missingmethodorid, sincecall_rawalways carries both; amethodthat is not a string, sincecall_rawrefuses to build one; and the bare[]empty batch, which neithercall_raw(one object per post) norcall_batch(refuses an emptycalls) can send. No well-formed, multi-member batch is posted anywhere in the suite, socall_batchitself has no existing case to convert.requestsis gone from thetestdependency group.postis rebuilt onbitcoin_core_rpc.http_request, the same seamcall_rawitself posts through, so a raw envelope no longer needs a second HTTP client to stay raw.types-requestsgoes with it fromlint.tests/integration/conftest.py’sBitcoindwrapper drives its client through aSessionTransport. A real bitcoind keeps an rpc connection alive across calls, unlike this node’s own listener, andreorg_test.pyandbackpressure_test.pyeach submit their chain onebitcoind.rpccall per block. Measured directly against a live regtest bitcoind, one connection kept open across many sequential calls answers each measurably faster than a fresh connection would; neither integration module’s own wall time moves, both being dominated by bitcoind’s own block validation rather than by the rpc round trip.A functional test drives several calls through one
SessionTransportagainst this node’s own listener and pins what it does with the connection:RpcConnection.async_sendcloses its socket unconditionally after every reply, with no idling and no keep-alive forSessionTransport’s own probe to find, so the transport reconnects on every call rather than reusing one – the harness stays on the default transport here, the measurement above being what argues forSessionTransportat the integration client and not for the functional one.
apply_rev_block undoes an in-block chained transaction (closes #634)¶
UtxoIndex.apply_rev_blockappliesto_addbeforeto_remove(src/btclib_node/chainstate/utxo_index.py), the reverse of the orderadd_blockbuilds them in. An ordinary chained transaction – one spending an output another transaction earlier in the same block created – puts that output’s outpoint in both lists:to_removefrom being created,to_addfrom being spent before the block ever finalized it to disk. Its net effect on the persisted set is nothing, both before the block and after it, and removing first looked it up while it was in neitherupdated_utxo_setnor the database, raisingChainstateInconsistencyError("output not found")on a block that did nothing wrong.The consequence was a stuck node, not a corrupted one.
update_chain’sto_removeloop (src/btclib_node/main.py) never setsfailed_hash, so the raise rolled the trial back without writing anything and without invalidating the block, and the next pass ofupdate_chainpicked the same fork again – any reorg disconnecting a block with a chained transaction, ordinary on mainnet, retried forever rather than completing.Core’s
DisconnectBlock(src/validation.cpp, at bitcoin/bitcoin@05e49b342f) reaches the same result walking one transaction at a time in reverse block order – spend its own outputs, then restore its own inputs – rather than in the two flat passes here; the two agree because a valid block spends a given outpoint at most once, so a chained output’s create and spend never collide with anything but each other.
A corrupted stored coin is this node’s fault (closes #631, closes #636)¶
Coin.parsereading back autxo-record insideUtxoIndex.get_coin’s own store fallback is now wrapped the way #620 wrappedUtxoIndex.add_block’s own read of the same kind of record: aBTClibValueErrorit raises re-raisesChainstateInconsistencyError, the key it reads being one only this node’s ownUtxoIndex.finalizeever writes, so a submitted transaction’s own bytes never reach that call.send_raw_transactionandtest_mempool_accept(rpc/callbacks.py) used to answer a corrupted own-stored record the same way they answer a genuinely bad script –VERIFY_REJECTED/"Invalid signatures or script"– misattributing this node’s own storage fault to the transaction it happened to be checking.Neither RPC handler needed a new catch of its own.
send_raw_transactionhas no clause forChainstateInconsistencyError, so it propagates intohandle_rpc’s own generic catch and is answered as an internal error instead of a refusal;test_mempool_accept’s own per-entry loop already carries a catch-all beneath its two specific ones, and reports the one entry"Unknown error"instead.The peer-to-peer
txhandler (p2p/callbacks.py) needed no change either. It already lets anything butMissingPrevoutErrorpropagate intohandle_p2p’s own generic catch, which stops that one connection and discourages the peer only for aBTClibException.ChainstateInconsistencyErroris a plainRuntimeError, so a corrupted own-stored record no longer discourages the peer that sent a transaction this node itself could not read back – aBTClibValueErrorreaching the same path used to.Core detects this at the storage layer, where this tree has nothing, so the two differ in where the fault is caught rather than in what either chose.
CDBWrapper::Read(src/dbwrapper.h:220-237, at bitcoin/bitcoin@05e49b342f) does catch a deserialize failure and treat the record as absent – but it never sees storage-layer corruption to begin with:CDBWrapperreads withverify_checksums = trueand a mismatch reachesHandleError, which throws beforeRead’s owntryruns at all, so what thattrycatches is a format mismatch on an intact record.db.py’s own store carries no equivalent guard, which makesCoin.parsethe only point this node ever learns autxo-record it wrote is unreadable back – at the read above and atUtxoIndex.add_block’s own read of the same kind of record (#620). Answering Core’s literal way there, silently absent, would fold a node-owned fault into an ordinary “prevout missing” refusal.ChainstateInconsistencyError’s own docstring carries the argument, including what about Core’s behaviour is not settled: whether its RPC and net-processing paths, which each catchstd::exception, absorbdbwrapper_errorwhere its ownAbortNodemachinery says the intent is to stop. Neither call site here rests on that. btclib-org/btclib-node#637 is the missing guard and btclib-org/btclib-node#641 is whether the store lacking it is the right one (#636).
The btclib floor is the release cut today¶
pyproject.tomlfloorsbtclib[secp256k1]at the sibling’s v2026.8.29, the release this tree is developed against: the one that puts btclib itself on the post-vendorabilitybitcoin-core-rpcand publishesSessionTransportin its fetch seam.uv.lock’s git pin moves to btclib’smainpast that release, which is what the suite exercises.
dev reaches every group this tree declares (issue btclib-org/.github#498)¶
pyproject.toml’sdevgroup now includesfuzzalongsidetest,lint,mutation,docsandcheck. Section 1 of the organization standard’s dependency-group table givesdevas every group above, andfuzzcarried no exception to it: itssys_platform == 'linux'marker is what makesuv syncon another platform resolve the group rather than refuse it, so a developer off Linux gets adevsync that installs nothing fromfuzzinstead of one that fails.uv.lockmoves with it.
A failed rollback stops the node instead of continuing on it (closes #623)¶
update_chain’sto_removeloop no longer swallows aChainstateInconsistencyErrorits ownapply_rev_blockraises (src/btclib_node/main.py): the exception now propagates out ofupdate_chain, the same way a non-content failure already does for theto_addloop,_blocks_to_add,_rev_blocks_to_removeand_finalize_fork, andNode’s own loop leaves and closes the databases on it. Undoing an already-connected block is this node’s own bookkeeping rather than a verdict on a new block, so the trial still rolls back first and still blames no block for it – only whether the failure is fatal to the node has changed, matchingDisconnectTip’s own failure being fatal one level up, inActivateBestChainStep(src/validation.cpp, at bitcoin/bitcoin@b91d983f66).
The suite passing free-threaded is now a claim the metadata makes (closes #593)¶
pyproject.tomldeclaresProgramming Language :: Python :: Free Threading :: 3 - Stable.test.yml’scoveragejob runs the whole suite on3.14t, at the 100% coverage floor, on every merge, and the classifier is that claim made to an index. The comment beside it says the claim is about the free-threaded build working and not about throughput: the UTXO store is the larger half in wall clock and is serial regardless of the interpreter build (issue #576).
pypi-install installs from the index (closes #502, closes #287)¶
.github/workflows/pypi-install.ymlinstallsbtclib-nodefrom PyPI weekly, at this repository’s own slot in section 10’s calendar, and checks it past a bare import. A bare import cannot see this package’s own runtime path:btclib[secp256k1]is an unconditional dependency, and a broken pair on the index degrades silently to the Python arithmetic rather than failing, so one step asserts the bindings serve and a second builds and starts aNodeon regtest, the same shapetests/conftest.py’s ownnode_contextalready exercises against a source tree. The image matrix is every platform this repository’s ownos-*sentinels already sample,windows-latestincluded: a stranger can install this from the index regardless of whatpyproject.toml’s classifiers claim, andos-windows.yml’s own accepted-red convention is what that cell inherits rather than re-decides.release.yml’spublish-pypijob calls the new workflow instead of carrying its own inline install-and-import check,needs: publish-pypipaired with its ownalways()and an explicitneeds.publish-pypi.result == 'success'– a bareneeds:here reads back throughpublish-pypi’s ownneeds:topublic-apitwo hops away, which a real breaking-change failure there would then skip silently despitepublish-pypiitself having succeeded (btclib-org/btclib#1470 is where that shape was measured on a tag).publish-testpypiuploads and stops, with no install-verify step of its own:pypi-install.ymlreadspypi.organd has nothing to say abouttest.pypi.org, matching every other repository that publishes.
The store moves to RocksDB, through rocksdict (closes #637, closes #641)¶
src/btclib_node/db.py’sKeyValueStoremoves from the standard library’ssqlite3to RocksDB, throughrocksdict, behind its unchanged interface. #641 is where sqlite3’s own lack of a checksum layer was measured againstCoin.parse’s own detection rate over a corruptedutxo-record (4% of single-bit flips caught, 96% parsed cleanly into a wrongCoin, 23% of those a silently wrong amount) and against the fault an in-record checksum alone (#637) cannot reach at all – a corrupted key, which makes the record it names cease to exist rather than read back wrong. RocksDB is LevelDB’s own fork and carries the per-block CRC32C checksum Core’s ownverify_checksums = truerelies on;rocksdictis what ships it as a wheel, on nine platforms, againstplyvel’s own deadcp314support.The configuration matches Core’s
GetOptions/DBParams(src/dbwrapper.cpp,src/dbwrapper.h) line by line, cited at bitcoin/bitcoin@ca7162cde5 indb.py’s own module docstring: no compression, paranoid checks, checksums verified on every read and scan, the storage engine’s own bloom filter (unrelated to BIP37’s deprecated, peer-relay one), and a block cache and write buffer sized from what Core’s own default-dbcachegives the coins database specifically, computed rather than guessed.The schema version moves out of
PRAGMA user_versionand into its own RocksDB column family, kept out of the default column family’s key order for the same reason the old version lived outsidekv’s own rows. The datadir marker inverts: RocksDB writes theCURRENTfile LevelDB used to, so the refusal this store raises is now for a directory holding the old store’s ownindex.sqlite, with the same message.A corruption
rocksdictitself detects is nowStoreCorruptionError(exceptions.py), raised at every read this store makes –get,__iter__, and the open itself – classified fromrocksdict’s own untypedException, whose message beginsCorruption:, since only itsDbClosedErrorcarries a type of its own to catch instead.ChainstateInconsistencyError’s own docstring is corrected where it used to say this tree carried no such layer; what aCoin.parsefailure now means on a record this new guard already passed as intact is btclib-org/btclib-node#650’s own question.__iter__and the internal read_check_schema_versionuses to tell an empty store from one this class predates both walk the store throughrocksdict’s lower-levelRdict.iter, not the higher-levelRdict.items/Rdict.keys. Measured directly: the higher-level wrappers answer a corrupted block the way they answer the genuine end of the store – nothing raised, fewer pairs than were written, in one case none at all – because neither ever calls the separatestatus()the underlying iterator reports its own fault through.db.py’s own module docstring has the two measurements.pyproject.tomlfloorsrocksdictat0.3.29, the release measured against; thesqlitekeyword becomesrocksdb, and.python-version’s own comment is corrected –rocksdictshipscp314wheels and nocp314twheel at all, where the comment used to claim every compiled dependency in the lock had one.RELEASE_NOTES.mdcarries the action this forces: a datadir written by the sqlite3 store cannot be read by this version, and is refused by name rather than silently started over as an empty chain.
getblockchaininfo answers a sync in progress (closes #575)¶
blocks,headers,bestblockhash,bits,target,difficulty,time,mediantime,chainwork,initialblockdownloadandprunedjoinchain, each with Core’s own meaning (src/rpc/blockchain.cpp, at bitcoin/bitcoin@ca7162cde5).headersisBlockIndex.header_index’s own height, tracked separately from the validatedactive_chainblocksanswers – the gap between the two is a header sync in progress, invisible until now to anything that could only readgetblockcount.initialblockdownloadisNode.is_initial_block_download, a new one-way latch onNodethat reproduces Core’s ownIsInitialBlockDownload(src/validation.cpp, at bitcoin/bitcoin@ca7162cde5): the active chain’s work must reach a new per-Chainminimum_chain_work(chains.py, cited tochainparams.cpp) and its tip must be no older than a newMAX_TIP_AGEof 24 hours (constants.py, cited toDEFAULT_MAX_TIP_AGE), checked inmain.update_ibd_status. This replaced a first pass that readNodeStatus’s own latch toBlockSyncedinstead, a second, looser definition of the same Core concept the review of this closing round caught.download.py’s_send_due_feefilterskeeps readingNodeStatus.BlockSyncedrather than moving onto the new latch: tried the other way once, and reverted, because a regtest chain built with this tree’s own default,GENESIS_TIME-relative timestamps never satisfiesMAX_TIP_AGE, so a node that had validated its entire known chain would advertise the topfeefilterbucket to every peer forever, and one measured functional test regression back that up (tests/functional/p2p/tx_test.py::test_send_tx, timing out against a peer refusing to announce a transaction below that bucket).verificationprogressstays absent: Core’s ownGuessVerificationProgressextrapolates fromChainTxData, an assumed per-chain transaction rate this tree does not carry, and answering a different number under Core’s own name would be answering the wrong thing under it.get_blockchain_info’s own docstring (src/btclib_node/rpc/callbacks.py) has the citation for every member answered and every member still left out.
btclib-node reads bitcoin.conf (closes #583, closes #581, closes #573)¶
pip install btclib-nodeputsbtclib-nodeonPATH([project.scripts],pyproject.toml), andpython -m btclib_nodereaches the same entry point (src/btclib_node/__main__.py) for a checkout nobody has installed yet.src/btclib_node/cli.pyis the module behind both, built onargparserather than click: this wheel’s SBOM (.github/scripts/generate_sbom.py) still names one runtime component,btclib.Every flag is spelled the way Core spells its own –
-datadir=<dir>,-conf=<file>,-chain=/-testnet/-signet/-regtest,-port=,-rpcport=,-rpcbind=,-prune=,-debug,-connect=,-addnode=,-listen=/-nolisten, each also accepted with a double dash – and-h/--helplists every one. A page walking an installed user through what the command actually enforces is still owed (issue #577), filed rather than written here.-conf’s file is read the way Core readsbitcoin.conf: akey=valueline per option named without its leading-, a[section]per chain (mainincluded) plus one default section,includeconf=resolved against the data directory, and the command line always taking precedence over the file.cli.py’s own module docstring is where the precedence, and every simplification against Core’s own reader, is argued.Configtakesconnectandaddnode, sequences ofhost:portpairsNode.rundials at startup throughp2p_manager.connect(peer_address(...)), the same route the integration suite already dials a realbitcoindthrough.-connectdials those alone and turns off DNS seeding, every automatically-drawn outbound connection (P2pManager.use_addrman_outgoing) and, by default, this node’s own P2P listener (P2pManager.listen) – Core’s ownInitParameterInteractionturns off the same three, and an explicit-listen/-listen=1still wins over that default the way Core’s ownSoftSetBoolArgdoes.-connect=0dials nobody but still counts as-connectfor that interaction, matchingconnect.size() != 1 || connect[0] != "0".-addnodedials alongside the ordinary draw. Reading Core’s ownblk*.datfiles is not implemented: the p2p route above already delivers the same blocks with no new parser.scripts/chains/mainnet.py,testnet.pyandsignet.pyare gone, replaced by the command above;tests/unit/scripts_test.py, which existed only to check their own module-body guard, goes with them –Node.__init__’s ownReimportedMainProcessError(issue #589) is what a re-imported__main__meets now, whichever entry point built theNode.-blocksdiris not implemented:BlockDBtakes one directory and giving it a second is wider than a flag, filed as btclib-org/btclib-node#652 rather than carried here. A standing reconnect for a dropped-connect/-addnodepeer is filed as btclib-org/btclib-node#651 for the same reason: each dials once, at startup, and Core keeps redialling either forever.
An RPC connection stays open across replies (closes #640, closes #642)¶
RpcConnection.async_sendkeeps its socket open across replies now, following Core’s own per-version keep-alive default (HTTPRequest::WriteReply,src/httpserver.cpp:557-575, at bitcoin/bitcoin@ca7162cde5), rather than closing unconditionally after every one: HTTP/1.1 stays open unless the request carriedConnection: close, HTTP/1.0 closes unless it carried an explicitConnection: keep-alive–run’s own_wants_keep_aliveis where the two are told apart, off the request line’s own trailing token, which used to be discarded entirely.bitcoin_core_rpc. SessionTransportpools a connection across calls and probes it before reuse, which the unconditional close made race: the probe could find the socket not yet readable and reuse it into the narrow window between this node’s own close and the client’s next request, surfacing a bareConnectionResetErrorSessionTransport’s own docstring already argues it does not retry.A malformed body’s own
PARSE_ERRORreply honours the same keep-alive rule as any other reply, and no longer answers it from insiderun’s own call frame. It used to force a close regardless of what the request asked, because the reply was awaited inline rather than scheduled the way a dispatched reply’sasync_sendalready is – honouring keep-alive there would haveruncall back into itself, unbounded, for a run of malformed bodies sent one after another over one kept-alive connection, eventually hitting Python’s own recursion limit, caught byrun’s own blind exception handler and closed with nothing logged. Scheduling the reply as its own task instead keeps every one of those frames independent, and is what makes honouring keep-alive here safe, matching Core’s ownHTTPReq_JSONRPC(src/httprpc.cpp:232-244, at bitcoin/bitcoin@ca7162cde5), which negotiates keep-alive on a parse error exactly like any other reply.That same reply now schedules through
self.loop.create_taskrather thanrun_coroutine_threadsafe, and its connection is no longer popped out ofmanager.connectionsuntil it is actually done.run_coroutine_threadsafe, called fromrun’s own thread – which is alreadyself.loop’s own thread, unlikesend’s cross-thread call to the same seam – only queues the Task’s creation for a later turn of that loop, leaving it briefly absent fromasyncio.all_tasks();RpcManager.stopreads that set exactly once to build what it cancels, and a connection already popped out ofmanager.connectionsbesides was unreachable by either of the two collectionsstopsweeps, for a turn where nothing had scheduled it yet.create_taskmakes theTaskexist immediately, and leaving the connection inmanager.connectionsuntilasync_senditself pops it keepsstop’s own socket-closing sweep able to reach it regardless.REQUEST_TIMEOUT– Core’s ownDEFAULT_HTTP_SERVER_TIMEOUT, unchanged – now also bounds the idle wait for a kept-alive connection’s next request, matching Core spending that one setting on both bounds rather than adding a second constant for the same fact.tests/functional/rpc/connections_test.py’s owntest_one_session_transport_still_opens_a_socket_per_call, which paced its calls to dodge the race above, is rewritten to assert the opposite: oneSessionTransportinstance keeps one connection across calls, and hundreds of unpaced ones do not reset it.tests/functional/rpc/batch_test.pydrives a well-formed, multi-member JSON-RPC 2.0 batch through a real socket, throughBitcoinCoreRpcClient.call_batch– answer ordering, a mixed valid/invalid batch,handle_rpc’s own stop-anywhere-in-the-batch rule, and a batch sharing its connection with the call after it – none of which any test belowtests/unit/rpc/reached before this.
A Coin.parse failure on an intact record now answers absent (closes #650)¶
UtxoIndex.add_blockandUtxoIndex.get_coinno longer raiseChainstateInconsistencyErrorwhen a storedutxo-record’s own bytes pass RocksDB’s own checksum (btclib-org/btclib-node#641) butCoin.parsestill cannot read them.add_blockfolds it into the sameInvalidBlockInputError(“prevout not found”) a genuinely missing prevout already raises;get_coinreturnsNone. Both matchCDBWrapper::Read/CCoinsViewDB::GetCoin’s own “absent” for a checksum-clean record that does not deserialize, read atbitcoin/bitcoin@ca7162cde5– the case #641 made reachable by moving genuine corruption to a separate, earlier guard (StoreCorruptionError), which is still raised and still not mapped onto this class.The cost is accepted, not hidden: a coin this node wrote itself, that a bug in this node’s own serializer alone can make unparsable on a checksum-clean read, is answered as absent, so a later, genuinely valid block spending it is rejected (
bad-txns-inputs-missingorspent,BLOCK_CONSENSUS) – Core pays the identical price for the identical reason, argued inChainstateInconsistencyError’s own docstring.ChainstateInconsistencyError’s andStoreCorruptionError’s own docstrings drop the two now-false raise sites and the “unsettled” framing, andCLAUDE.md’s Following Bitcoin Core paragraph is rewritten to describe this tree’s checksum as landed rather than as a gap still open;RELEASE_NOTES.md’s own #631 entry now scopes its “this node’s own fault” answer to what the checksum catches, and says beside it that a checksum-clean recordCoin.parsecannot read is answered as absent, with nothing logged.The two tests from #620 and #631 assert the decided behaviour: a real, on-disk truncated
utxo-record (not a monkeypatch) now rejects the candidate block asInvalidBlockInputErrorand answers a mempool spend of itMissingPrevoutError, in place of theChainstateInconsistencyErrorboth used to assert.
getblockheader answers Core’s own field names and types (closes #658)¶
chainworkis hex, zero-padded to 64 digits, matching Core’s ownnChainWork.GetHex()(src/rpc/blockchain.cpp:184, at bitcoin/bitcoin@ca7162cde5) rather than the plain JSON numberblock_index.chainwork[block_hash]used to answer un-encoded: a client written against Core parsing this the way it parsesgetblockchaininfo’s ownchainwork(#575) got a JSON number where it expected a string.versionHex,mediantimejoinversion,nonce,bits,target,difficulty, each matching Core’s own name and type (src/rpc/blockchain.cpp:170-184, same commit);merklerootandpreviousblockhashreplace btclib’s ownto_dict’smerkle_rootandprevious_block_hash, which this callback used to answer verbatim under btclib’s own snake_case names rather than Core’s – forprevious_block_hashspecifically also for a genesis-height block, whereto_dictanswered an all-zero hash under that name and Core answers nopreviousblockhashmember at all.timeis now the raw timestampto_dict’s own ISO 8601 string was standing in for.nTxstays absent: Core reads it offCBlockIndex::nTx, a count kept beside the header once the block is received, andBlockInfo(chainstate/block_index.py) carries no such count – answering it here would mean parsing the whole block body offblock_dbfor every call, a header lookup paying a block’s own cost.
The feefilter gate reads the same IBD flag Core does (closes #661)¶
DownloadManager._send_due_feefiltersreadsnode.is_initial_block_download, matching Core’s ownMaybeSendFeefilter(net_processing.cpp, at bitcoin/bitcoin@ca7162cde5), in place ofNodeStatus.BlockSynced: a second, looser definition of the one Core concept #575 already built a faithfulNode.is_initial_block_downloadfor. The swap had been tried and reverted once before, in #575’s own round 2, because the suite’s regtest fixtures dated every blockGENESIS_TIME-relative (2011), far older thanMAX_TIP_AGEever tolerates, so a node that had validated its whole known chain still never left IBD and kept every peer on the top feefilter bucket forever –tests/functional/p2p/tx_test.py::test_send_txwas the one test that measurably broke under it.generate_random_chain(tests/__init__.py) takes an optionaltip_time, reachingbuild_block’s owntimeoverride for only the chain’s last block, so a caller whose test needs a recent tip gets one without every earlier block losing theGENESIS_TIME-relative dating BIP34 height and coinbase-maturity arithmetic elsewhere in the same chain still depend on.test_send_txis that caller now, both of its nodes’ chains built with a tip timestamped against the real clock, closing the gap #575’s round 2 left open.
A one-member JSON-RPC batch is answered as an array (closes #653)¶
RpcConnection.async_sendnow unwraps a reply to a bare object offself.is_batch, set byrunfrom whether the request it just parsed was a JSON array, rather than offlen(response) == 1. A lone request and a one-member batch both reachedasync_sendas aresponselist of the same one-element shape, with nothing left at that point to tell the two apart, so a batch of exactly one member used to be unwrapped the same way a lone request is. Matches Core’s ownExecuteHTTPRPC, which decides singleton against array at parse time (valRequest.isObject()againstvalRequest.isArray()) and answers an array of any size as an array,UniValue::VARR, never unwrapped for holding only one member (HTTPReq_JSONRPC,src/httprpc.cpp:114and:135-169, at bitcoin/bitcoin@ca7162cde5). An empty[]batch is unchanged, still answered as the singleInvalid requestobjecttests/functional/rpc/errors_test.py:: test_an_empty_batch_does_not_end_the_nodealready pins – a separate question this tree already answered before this issue and does not revisit here.
A load-contention discriminator for the unpaced-calls test (closes #664)¶
tests/functional/rpc/connections_test.py:: test_many_unpaced_calls_over_one_session_transport_do_not_reset’s own docstring now names the shape of a load-caused failure, matchingCLAUDE.md’s own coverage-floor bullets (issue #372, issue #617): measureduptimevalues from two real occurrences, what confirmed each as external contention rather than a regression, and what to check before trusting a future failure of this one test. The call count is unchanged – shrinking it would trade away the statistical confidence issue #640’s own report chose 300 for, without removing the underlying exposure a documented discriminator already answers.
testmempoolaccept no longer swallows a store error (closes #668)¶
test_mempool_accept’s own blindexcept Exceptionno longer catches aStoreCorruptionErrorraised while reading a prevout. It now catches onlyBTClibValueErrorandMissingPrevoutError–verify_mempool_acceptance’s own two verdicts on the candidate transaction – and lets anything else propagate, ending the whole batch rather than reporting it as that one entry’s own"reject-reason". This matches Core’s owntestmempoolaccept(src/rpc/mempool.cpp:379-430, at bitcoin/bitcoin@ca7162cde5), whose per-tx loop never catches anything either – it only ever branches on theTxValidationResultProcessTransactionreturns rather than raises – so a genuine fault reachesExecuteCommand’s own uniform catch (src/rpc/server.cpp:874-887, same commit) instead, this tree’s own equivalent beinghandle_rpc(rpc/main.py), which already logs and answersINTERNAL_ERRORfor exactly this, the same catchsendrawtransactionalready relies on for a store error past its own two excepts.RELEASE_NOTES.md’s #631 entry is corrected to say so: it used to tell an operator to read the log forStoreCorruptionErrorwheretestmempoolacceptwrote nothing there to find, the gap this closes.A rewritten
send_raw_transactiontest in the same file swaps its own monkeypatched stand-in fromChainstateInconsistencyErrortoStoreCorruptionError, matching whatverify_mempool_acceptancecan still actually raise for this node’s own storage now that #650 stoppedUtxoIndex.get_coinraising the former.
-blocksdir puts BlockDB’s own files on a disk of their own (closes #652)¶
Configgainsblocks_dir,Noneunless a caller names one, chain-suffixed and made absolute the same waydata_diralready is;BlockDB.__init__takes it as an optional third argument and falls back todata_dirwhen it is not given, Core’s own"default: <datadir>"(-blocksdir=<dir>’s own help text).-blocksdirnaming a directory that does not already exist is fatal,Config’s own refusal, matching Core’s “Specified blocks directory … does not exist” rather than creating one silently.-blocksdir=<dir>reaches the command line andbitcoin.conf(_RECOGNIZED_KEYS, not network-only, matching Core’s own registration): unlike-datadir, it never decides which file is read, so nothing stops it being set inside that same file.
A dropped -connect/-addnode peer is redialled (closes #651)¶
P2pManager.manage_connections’s own 0.1s loop gains_maybe_redial_specified, one shared, capped, doubling backoff (1s floor, 60s cap) covering bothConfig.connectandConfig.addnode, reset to the floor the moment a peer is seen connected. Core keeps a whole thread apiece for this –CConnman::ThreadOpenConnections’s own-connectarm andThreadOpenAddedConnections– each with its own cadence; this node reuses the one loop it already has for both rather than replicating either cadence exactly, argued inp2p/manager.py’s own comment beside the two new constants.The very first pass never races
Node.run’s own one-shot dial (issue #573): each peer’s own next-eligible time is seeded a full backoff floor into the future inP2pManager.run, immediately beforemanage_connectionsis scheduled, rather than at construction time, when the gap tostart()is unknown.
P2pManager/RpcManager accept off a task, not a reader (issue #430)¶
server’s own accept loop no longer callsloop.add_reader, which Windows’ default Proactor loop does not implement at all, raisingNotImplementedErroron every bind. A task awaitingloop.sock_acceptin a retry loop replaces it, on both managers, reachable bystop’s ownasyncio.all_taskssweep the same way the reader callback’s own shielded task was.That task’s own failure is logged rather than left for asyncio’s “Task exception was never retrieved” warning, timed to whenever the garbage collector reaches it rather than to the failure itself:
runnow attaches_report_server_failuretoserver’s own scheduled task.Connection._closeno longer raises where the loop it is closing has no reader or writer to remove –remove_reader/remove_writershareadd_reader’s own absence on Proactor, so a connection’s socket went uncleaned on every close attempted there.install_signal_handlersno longer registersSIGTSTPunconditionally – the signal does not exist on Windows, and the baresignal.signalcall raisedAttributeErrorlooking it up before ever reachingsignal.signal.Connection.run’s read loop treats asock_recvthat raisesOSError–ConnectionResetError,ConnectionAbortedError– the same hangup an empty read already was, rather than letting it propagate:socket.socketpair()’s own Windows fallback, a real TCP loopback pair rather than a kernel-backed one, answers an abrupt local close with a hard reset a POSIX pair never sends, matching Core’s ownSocketHandlertreating everyRecvfailure that is notWOULDBLOCK/MSGSIZE/EINTR/EINPROGRESSas a disconnect rather than a crash.The gate cell and classifier
os-windows.yml’s own header points at stay this issue’s rather than landing here: dispatched runs against this branch still fail on real-node functional tests unrelated to this listener defect, filed as their own issues rather than guessed at here, so this closes none of what #430 asks for closed.
REPOSITORY.md’s required-check PATCH sends the checks array (closes #657)¶
The documented
PATCHofmain’s required checks sends thechecksarray as a JSON body on stdin, each entry bound to the Actions app, and the read-back beside it quotes.checks[]with itsapp_idrather than.contexts[]. Thecontextsform the section carried since btclib-org/btclib-node#264 would have replaced the four names bound to the app with the same four bound to nothing, and the.contexts[]read-back answers identically either way, so the record was a command that creates the drift and a check that cannot see it (section 11 of the organization standard). The live setting is bound today and was not changed: the new read-back’s example output is its answer at 991b385.The topics example under Features, and the topics reads
rocksdbwhere it readsqlite, the setting having been aligned topyproject.toml’skeywordswhen btclib-org/btclib-node#641 landed (issue #660).
A MuHash commitment to the UTXO set, and gettxoutsetinfo (closes #639)¶
chainstate/muhash.pyis aMuHash3072matching Core’s own construction bit for bit: the same 3072-bit safe-prime modulus, the same ChaCha20-keyed-by-SHA256 per-element hash, and the same numerator/denominator fraction that lets an element be inserted or removed in either order without recomputing the rest of the set – checked directly againstcrypto_tests.cpp’s ownmuhash_testsand RFC 7539/8439’s own ChaCha20 vectors (tests/unit/chainstate/muhash_test.py,tests/_data/README.md’s own new entry). The arithmetic is native Pythonint, in place of Core’s own fixed-width limb representation –CLAUDE.md’s own Python-native licence for this axis.CoinStatsbundles that accumulator withCoinStatsIndex’s own three running counters – the output count, the total amount, the bogo size – andUtxoIndex.add_block/apply_rev_blockmove all four together with every coin they stage, undone byrollbackthe same way a staged coin already was: an insert’s own undo is a remove of the same element, needing no prior accumulator state recorded.KeyValueStore.get_meta/put_meta(db.py) are what let it persist in the store’s own meta column family, inside the samewrite_batchas the coins it commits to, so a crash cannot leave the two out of step.A provably unspendable output – a leading
OP_RETURN, or a script overMAX_SCRIPT_SIZE– is never hashed into the commitment, matchingCCoinsViewCache::AddCoin’s own refusal to add one to Core’s UTXO set at all.UtxoIndex’s ownutxo-store still keeps every output regardless of spendability, a pre-existing divergence this branch does not close (issue #667).gettxoutsetinfoanswershash_type: "muhash"and"none", with Core’s own field names and units –height,bestblock,txouts,bogosize,muhash,total_amount.hash_type: "hash_serialized_3"is refused with Core’s own “not a valid hash_type” error, this tree answering only the indexed shape and not the from-scratch double-SHA256 scan Core computes it with;hash_or_heightis refused the way abitcoindrunning without-coinstatsindexalready refuses it, since this tree has no historical snapshot to answer a past height from either.transactionsanddisk_sizeare left out the same way Core’s own indexed answer already leaves them out, for the identical reason: both are anO(n)count over the whole set, which an incremental accumulator exists specifically to avoid paying on every call.
A unit test no longer binds regtest’s own well-known port (closes #678)¶
The
a_managerfixture’s defaultportisget_random_port(), drawn fresh on every call rather than the fixed18444: a test that starts a manager without naming its own port used to bind regtest’s own well-known port for real, colliding with a second suite of this same tree on one machine – two sessions, or one session’s coder and reviewer – and, within one-n autorun, possibly with a second worker’s own manager at the same default.test_connect_and_explicit_listen_binds_and_dialsis the one this was measured landing on.wait_until_listening’s own error already named the port a wait gave up on, so a future collision stays legible without a change there.
A page for running a node, and what a sync costs (closes #577, closes #576)¶
docs/source/running_a_node.mdcovers the command against each of the four chains,-connect/-addnodefor pointing at a peer of one’s own, reading a sync’s progress offgetblockchaininfo, the fourteen RPC methods, and what this node validates against what it does not, linking the issues rather than restating them;README.mdpoints at it, andindex.rst’s toctree carries it into the docs build.The page also carries this tree’s first measurement of an initial block download (#576): a mainnet header sync, block validation over two ranges of different density, and the store’s own cost curve, each dated to the commit it was measured against. The store figure is the sqlite3 store this tree used at the time of the measurement, not the RocksDB store it ships now (#641); no committed script exists here to re-run it, so the page says the RocksDB figure is unmeasured rather than guessing at one.
tests/interpreters_test.py compares POSIX paths everywhere (closes #663)¶
_RUN’s own keys arepath.relative_to(_ROOT).as_posix()rather thanstr(path.relative_to(_ROOT)). APurePath’s own__str__renders withos.sep– backslashes on aWindowsPath,pathlib’s own documentation forPurePath.__str__– while_NAMES_ONEbeside it is written with forward-slash literals, sostr()there compared a Windows path against a POSIX one onwindows-latestand nowhere else,_NAMING == _NAMES_ONEfalse for every entry despite naming the same file.as_posix()renders with/on every platformpathlibruns on, POSIX included, so this changes nothing observable there.tests/fuzz_corpus_test.py’s own_SEED_IDScarries the samestr(path.relative_to(...))shape but not the same defect: it only ever feedspytest.mark.parametrize’s ownids=, a display string nothing compares against a literal, so it is left asstr().
REPOSITORY.md says which settings it covers (issue btclib-org/.github#551)¶
Its opening claims the settings the standard asks about, in place of the whole of what is set outside the tree: the ones section 16’s checklist sets on a new repository, and the ones a section of the standard states a rule for, together with whatever a call quoted for one of those answers alongside. That is the scope section 11 of the organization standard fixes, and the claim it rejects is the one no command checks: the repository document answers with fields the standard states no rule about, and telling those from the settings a repository decides is a reading rather than a call.
A What this file passes over section at the foot is the other edge of that scope, each part of it with the call that reads it back: the document fields no section quotes and the standard asks nothing of, the facilities that answer empty, and
CLAUDE_CODE_OAUTH_TOKEN, which the organization holds and this repository sets nothing for. Every block carries a control that answers non-zero, so a zero beside it is an absence rather than a pattern that cannot match.use_squash_pr_title_as_defaultis read back in Merge methods, besidesquash_merge_commit_title: GitHub’s own API description marks the first as closing down in favour of the second, and section 11 of the standard states a rule about the squash title, so the older spelling of it is not a field the standard is silent about.
An empty JSON-RPC batch is answered [], matching Core (closes #669)¶
RpcConnection.is_batchis now plainisinstance(body, list), with no length condition, andhandle_rpcno longer injects anInvalid requestobject for an empty batch. An empty[]used to be excepted fromis_batchon purpose, so #653’s own array-shape fix left it answered as a single error object – a literal reading of JSON-RPC 2.0 section 6’s own wording for a batch that “fails to be recognized… as an Array with at least one value.” Core’s ownExecuteHTTPRPCdoes not read it that way: the array branch loops zero times over an emptyvalRequest, and its ownHTTP_NO_CONTENTexception is gated onvalRequest.size() > 0, so an empty input falls through unchanged and is answered with the empty array it started as (src/httprpc.cpp:135-185, at bitcoin/bitcoin@ca7162cde5). This tree now matches that.tests/functional/rpc/errors_test.py:: test_an_empty_batch_answers_an_empty_array(renamed fromtest_an_empty_batch_does_not_end_the_node) andtests/unit/rpc/main_test.py::test_an_empty_batch_answers_an_empty_response(renamed fromtest_an_empty_batch_is_an_invalid_request) now assert Core’s own answer.
add_block no longer stores a provably unspendable output (closes #667)¶
A leading
OP_RETURNor an over-MAX_SCRIPT_SIZEscript is never written under autxo-key at all, matchingCCoinsViewCache ::AddCoin(src/coins.cpp:82, at bitcoin/bitcoin@ca7162cde5), which returns without adding such an output to Core’s own UTXO set in the first place._stage_creation(chainstate/utxo_index.py) is the one gate both ofadd_block’s own creation loops share, and its return value is what keepsRevBlock.to_removefrom ever naming an outpoint this call never wrote: an output never stored is never restored,apply_rev_blockneeding no gate of its own for either side of the undo.gettxoutsetinfo’stxoutsandbogosizeare unchanged: both were alwaysCoinStats’s own running count (issue #639), gated on the identicalis_unspendableindependently of what theutxo-namespace held, never a scan of that namespace – this closes the gap between what the store persists and what the RPC answers, not a gap in the RPC itself.An existing data directory needs nothing. The entries a pre-#667 node already wrote for a provably unspendable output stay exactly where they are – harmless, since nothing ever looks one up as a prevout, and never revisited by this change, which only ever gates a new write. Core’s own answer to the same question is a full
-reindex-chainstaterebuild, not an incremental scrub of the coins database in place; this tree implements no such rebuild yet, so there is nothing to run and nothing to invent one for.
A directory as a conf file is refused the same way everywhere (closes #684)¶
_read_conf_file(cli.py) checkspath.is_dir()before opening the file, rather than catching theOSErrorsubclass an open attempt raises. Opening a directory raisesIsADirectoryError(errno.EISDIR) on POSIX andPermissionError(errno.EACCES) on Windows, so a handler written for one platform’s exception class never catches the other’s, andtest_read_conf_file_a_directory_raisesfailed onwindows-latestwith the wrong exception propagating uncaught. Theis_dir()check matchesReadConfigFiles’s ownfs::is_directoryguard, which likewise runs before its stream is ever opened.
-datadir naming a non-directory is refused, not a crash (closes #693)¶
build_config(cli.py) checks an explicit-datadirwith a new_check_datadirbefore it is ever joined intoconf_path, refusing it with aValueErrorwhere the path does not already exist as a directory – missing, or blocked by a file at the path itself or at any parent component of it – the samefs::is_directorytest and the same “does not exist” wording Core’s ownCheckDataDirOption(src/common/args.cpp:891, at bitcoin/bitcoin@ca7162cde5) uses, fatal there too. Before this, a datadir a file blocks reached_read_conf_file’spath.read_text()unchecked and raisedNotADirectoryError, uncaught, rather than theValueErrorevery other configuration problem in this module raises. The default (unset-datadir) path is not checked at all and keeps the lazy creationNode.__init__’s ownmkdir(exist_ok=True, parents=True)already gives it, matching Core’s own default path, which is never checked either and is created the same way, lazily, byGetBlocksDirPath’sfs::create_directories.
Windows: dial reaches IPv6, notices a refusal (closes #681, closes #682)¶
dial’s own peer tuple is four elements for anAF_INET6target, not two. Windows’ Proactor loop hands a bare(host, port)straight toConnectEx, and CPython’sModules/overlapped.c(parse_address, read at python/cpython@v3.14.0) dispatches on the tuple’s length rather than on the socket’s own family: a two-tuple is always parsed asAF_INET, soWSAStringToAddressWreads"::1"as an IPv4 dotted quad and answersWSAEINVALbeforeConnectExis ever reached. The four-tuple form,(host, port, 0, 0), names the family explicitly and is accepted on every platform this node runs on._DIAL_TIMEOUTis 5.0 seconds, Core’s ownDEFAULT_CONNECT_TIMEOUT(src/netbase.hat bitcoin/bitcoin@ca7162cde5), not the 1.0 the old poll loop’s own ten-passes-at-0.1s budget left behind. That figure was never checked against Core’s own default: it was too tight for what Windows’ Proactor loop needs to notice a refused loopback connect, measured at a little over two seconds on a Windows run and well inside Core’s own five-second budget.tests.local_addrnames127.0.0.1, not0.0.0.0. A POSIX kernel remaps a client connect to0.0.0.0onto loopback; Windows’ConnectExrefuses it outright, synchronously, which is what left every two-node functional test dialling through this helper waiting out its own 60-second timeout for a connection Windows never made.
A parametrize id fits in an environment variable (closes #701)¶
test_is_unspendable_matches_core’s two largest vectors get an explicit, shortids=rather than pytest’s own default. Abytesparameter with no explicit id is escaped byte for byte, so the 10,000/10,001-byte all-zero vectors turned into a node id of roughly 40,000 characters;PYTEST_CURRENT_TEST, set to that id plus" (setup)"/" (call)"/" (teardown)"throughos.environ[...] =, goes through_wputenv_s/SetEnvironmentVariableWon Windows, which refuses a value past 32767 characters –ValueError: the environment variable is longer than 32767 characters, at both setup and teardown, on every Windows run of btclib-org/btclib-node#681/#682’s own branch. POSIX has no such limit, which is why the suite was green everywhere else. The six new ids name the vector by what it is rather than by its bytes, matching the inline comments already beside each one.No other
parametrizein the tree carries this risk. Every one without an explicitids=parametrizes on a short, bounded string or a small int – the longest id the tree collects without one istest_is_bip30_unspendable’s, 141 characters, its"00" * 32being a 64-character string rather than bytes escaped four to one – and every one whose values arebytesor read from a corpus already carries its ownids=.
REPOSITORY.md’s alert paragraph answers what is true today (closes #680)¶
The Security and analysis section’s alert calls now print the answer they read back, and the paragraph under them says what is true of it. The second call’s default listing carries every alert state, not only
open, and every alert on this tree today answersdismissedorfixed, noneopen. By manifest it is 26poetry.lock(a file this tree no longer has) and 1uv.lock, this tree’s own; theuv.lockalert ispytest, medium severity, alreadyfixedat9.0.3. The paragraph used to point its remaining step at the closed issue #37; it now defers to no issue, stating the split outright and naming a future openuv.lockalert as its own finding rather than a step recorded here.
Two more of the parsers a peer reaches unauthenticated are fuzzed (issue #516)¶
p2p.connection.frame_message/frame_message_bytespullConnection.parse_messages’s own per-message framing – the header peek,Message.parse, and the network-magic check right after it – into a function of a stream (or, for the second, bare octets) rather than an inline loop body, the same shape Core’s ownV1Transport::ReceivedBytestakes in its ownp2p_transport_serialization.cppfuzz target (at bitcoin/bitcoin@ca7162cde5): a transport built with no socket, fed raw bytes directly.parse_messagesitself now callsframe_messagerather than repeating its body; nothing about what it parses, queues or rewinds on a partial message changed.rpc.connection.parse_request_headpullsRpcConnection.run’s own header-section framing – the request line, the header fields, theContent-Lengthbound and the keep-alive decision – into the same shape, scoped the way Core’s ownhttp_request.cppfuzz target is scoped: control-line and header framing only, never the JSON-RPC body decoding stdlib’s ownjson.loadsalready does. Two new exceptions,IncompleteRequestHeadErrorandMalformedRequestHeadError(btclib_node.exceptions), are what it raises for octets a live connection’s own_recv_untilwould never hand it but a fuzzer can – bothBTClibExceptionsubclasses, sorun’s own bareexcept Exceptioncatches them exactly as it caught theConnectionErrorthis replaces.fuzz/fuzz_framing.pyandfuzz/fuzz_rpc_head.pyare the two new atheris harnesses over those entry points, each with a seed corpus underfuzz/corpus/;fuzz.ymlnow runs all three harnesses in one job,-max_total_timesplit three ways rather than tripled so the job’s own 30-minute ceiling does not grow with the harness count..pre-commit-config.yamlexcludesfuzz/corpus/fromtrailing-whitespace,end-of-file-fixerandmixed-line-ending: measured againstfuzz_rpc_head’s own seeds, whose valid HTTP request heads are printable ASCII ending in\r\n\r\n,end-of-file-fixerdropped one of the required pair andmixed-line-ending’s own--fix=lfrewrote every\r\nto\n, each silently turning a valid seed into oneparse_request_headitself would never produce or accept.The p2p callbacks (
p2p/callbacks.py) are not fuzzed. Core’s own matching target for that layer,process_message.cpp, builds a wholeTestingSetuprather than feeding a bare object octets, and this tree’s own handlers likewise need aNode, aP2pManagerand aConnectionrather than a buffer – a materially different harness shape, not a small refactor away from the other two. Left open, tracked forward as issue #698.
test_download no longer copies a running node’s LOCK files (closes #683)¶
tests/functional/p2p/download_test.py::test_downloadcopies its bootstrap node’s directory withshutil.ignore_patterns("LOCK"), seeding the other nine download peers from everything but the threeLOCKfiles RocksDB keeps open for as long as a store is (src/btclib_node/db.py) – the bootstrap node stays running throughout the copy, as a peer the rest of the test connects to. Windows refuses to copy a file another handle still holds where POSIX does not, soshutil.copytreefailed onwindows-latestwithWinError 32on those three files. Excluding them costs nothing a fresh peer needs: RocksDB re-creates a store’sLOCKfile on everyRdictopen regardless of what, if anything, was already at that path, measured directly by opening aNodeagainst a directory copied this way.
db.py records that a closed store’s LOCK releases synchronously (issue #703)¶
src/btclib_node/db.py’s lock section says whatclose()does to the directoryLOCK, source to source, and what one Windows run measured. ISS 703 was filed on onewindows-latestrun of a branch of #683 in which the threeLOCKfiles were still held immediately after every store’s ownclose()had returned. Read at the versionsuv.lockpins –rocksdict0.3.29, itsrust-rocksdbfork at Congyuwang/rust-rocksdb@4cf3c68a993b807bc54ff1c5293cdf49e62aaf72, RocksDB at facebook/rocksdb@44e95d8af5d7ec503b3f1d5754c3379ab6c29a9d – every link fromRdict.close()to::CloseHandleon the lock is a synchronous call on the calling thread:DbReferenceHolder::closedrops the lastArcclone,DBCommon::dropreachesffi::rocksdb_close,DBImpl::CloseHelperwaits out background compaction and flush beforeenv_->UnlockFile, andWinFileSystem::UnlockFileis a baredeleteintoWinFileLock::~WinFileLock. Measured once onwindows-latestwith a bounded, timed retry around a reopen straight afterclose()(run 33274969391): reopened at the first attempt, in 0.091 s. Nothing here is deferred to a background thread or to the garbage collector, so the one held lock reads as contention on a loaded runner rather than a defect this tree orrocksdictowes a fix for; the issue is closed on that measurement and the section records it, so the next reader of aWinError 32on aLOCKfile starts from the trace rather than from the guess.
test_download’s copies now hold the whole chain (closes #710)¶
test_downloadflushesbootstrap_node’s chainstate before copying its directory._finalize_forkonly callsChainstate.flushonceUtxoIndex.should_flushsays the staged UTXO cache has grown past its own bound (src/btclib_node/main.py), a bound this test’s own chain of coinbase-only blocks never reaches:BlockIndex.stage_statusandFilterIndex’s ownpendingheld everything in Python state, never written to the store, so the nine copies taken frombootstrap_node’s directory opened on essentially empty stores – genesis alone – while the test still passed,bootstrap_nodeitself answering every request as the same live Python object regardless of what had reached disk. Each copy now asserts its own chain length right after it opens, before it ever talks to a peer, rather than relying on the final assertion againstmain_node, whichbootstrap_nodealone already satisfies.
handle_rpc no longer drops a kept-alive reply (closes #688, closes #685)¶
rpc.main.handle_rpcno longer popsRpcManager.connections’s own entry for the connection it just answered. It used to, right after scheduling the reply throughRpcConnection.send, unconditionally, onNode’s own thread – racingRpcConnection.async_send, onRpcManager’s own thread, writing that same reply, re-arming the connection for its next kept-alive request and reading that next request whole, all without ever suspending in between. Whereasync_sendwon that race,handle_rpc’s own pop removed the entryasync_sendhad just put back for the request already queued behind it, sorpc.main.get_connectionfound nothing for it and the request was silently dropped, answering nobody: the client blocked for its own full per-call timeout with no reply, no close and no reset ever arriving, and nothing on the server’s own side to say why.RpcConnection.async_sendis now the sole owner of this dict’s membership, on the branch that already ran there for a reply that closes.This is what
tests/functional/rpc/connections_test.py:: test_many_unpaced_calls_over_one_session_transport_do_not_resetwas failing standalone on, at a load issue #664 had read as ordinary contention – that docstring is corrected to say so.
A 2 s wait’s own bound survives a kernel tick (closes #704)¶
test_send_and_wait_gives_up_rather_than_blocking_foreverassertswaited >= 2 - _WINDOWS_TIMER_TICK, notwaited >= 2, after two Windows runs failed at1.9998... >= 2, a few hundred microseconds short.send_and_wait’sfuture.result(timeout=2)reaches_thread.lock.acquire(True, 2), which CPython 3.14 implements aslock_PyThread_acquire_lock(Modules/_threadmodule.c:814-833) calling_PyMutex_LockTimed(Python/lock.c:53, read at python/cpython@v3.14.0) – not the legacyPyThread_acquire_lock_timedinPython/thread_nt.hthat an earlier version of this fix cited, which_thread.Lockno longer reaches in 3.14._PyMutex_LockTimed’s own deadline readsPyTime_MonotonicRaw(lock.c:67,148; on WindowsQueryPerformanceCounter,Python/pytime.c:1065-1090), the same clocktime.monotonic()itself reads (PyTime_Monotonic,pytime.c:1223-1225) – so the lock’s own deadline and the test’s own measurement are not two different clocks, contrary to that earlier fix’s own claim. On Windows the wait parks through_PyParkingLot_Parkinto_PySemaphore_PlatformWait, which callsWaitForMultipleObjects(Python/parking_lot.c:95-133, the call at:130) with a millisecond count from_PyTime_AsMilliseconds(timeout, _PyTime_ROUND_TIMEOUT)(parking_lot.c:105) – notWaitForSingleObject. The real gap is one level deeper: between that millisecond count and the interrupt-time tick clock the OS kernel actually satisfies the wait against, which Microsoft’s own Remarks forSleepdescribe for the same tick-based wait timer (WaitForMultipleObjectsdocuments no accuracy of its own): “The system clock ‘ticks’ at a constant rate… If dwMilliseconds is greater than one tick but less than two, the wait can be anywhere between one and two ticks, and so on.” (https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-sleep). A 2000ms wait can therefore be satisfied up to one tick early on the QPC-backed clocktime.monotonic()reads._WINDOWS_TIMER_TICKis set to 15.625ms, the documented default tick – generous next to the two measured shortfalls and small next to the two-second bound itself. A review round caught the first version of this fix citing a Windows call path (Python/thread_nt.h’sWaitForSingleObject) that is real code at that sha but not the one_thread.Lockreaches in 3.14, and a “two clocks” story the source does not support – fixed here to the call path and deadline arithmetic actually traced.
RpcConnection.close no longer cancels a stale handle (closes #714)¶
RpcConnectionno longer carries ataskattribute, andcloseno longer tries to cancel one. It was set once, at accept, and never again – every request on a kept-alive connection after the first is read by a fresh call torunfrom insideasync_send, which never touched it – so past a connection’s first requestclose’s ownself.task.cancel()named a long-finishedFutureand cancelled nothing.RpcManager.stop’s ownasyncio.all_tasks(self.loop)sweep is what actually cancels whatever is live for a connection, run beforecloseis ever called;closenow only closes the socket, matching Core’s ownHTTPRemoteClient, which carries no handle onto whatever worker thread might be answering a request for it either –HTTPServer::ClearConnectedClients(src/httpserver.cpp:1160-1167, at bitcoin/bitcoin@ca7162cde5) drops what is left the same unconditional way, once its own socket-handling thread has already been joined.
The p2p callbacks are fuzzed through a node (closes #698, closes #516)¶
fuzz/fuzz_process_message.pyis the third parser #516 left open: one atheris harness drivingp2p.callbacks’ every handler –handshake_callbacksthroughp2p.main.handle_p2p_handshake,callbacksthroughhandle_p2p– the wayNode’s own loop does, over one regtestNodebuilt once and reused across every call, cheaper by orders of magnitude than rebuilding one per call. Core’s ownprocess_message.cppdiffers from this rather than being the same shape at less cost: it discards and rebuilds its ownconnman,banman,addrmanandpeermanfresh on every single pass, for a sanitizer reason – catching a dangling pointer – this harness’s own Python objects have no counterpart to, and resets onlychainmanandmempoolconditionally, on a pass that leaves either dirty. This harness’s ownNodeis reused whole, with nothing inside it rebuilt or reset either way.dispatch’s own docstring is where that comparison, and what it costs to leavepeer_dbunreset across calls, measured the same way, are argued.The command a message dispatches under is picked by index from the fixed set
p2p.callbacksactually dispatches, not read from whatever twelve octets the fuzz input itself spells – Core’s ownLIMIT_TO_MESSAGE_TYPEexists for the same reason, an environment variable this tree’s own script-per-harnessfuzz.ymlhas no per-target corpus to key one on, so the harness takes the same outcome structurally instead. A header is built from the command and payload throughbtclib.p2p.message.Message, so only the command and the payload are ever fuzzed and the header is always well-formed –fuzz_framing.pyalready fuzzes the header on its own.p2p.main.handle_p2p/handle_p2p_handshakethemselves catch anything a callback raises, discouraging the peer only for aBTClibExceptionand otherwise only logging, so a harness that merely called them and trusted what escaped would never see a callback’s own bug – nor tell a callback’s own refusal of the input apart from genuine acceptance, both landing ondispatch’s ownNonereturn otherwise._CrashCapture, alogging.Handlerattached directly tonode.logger– the one way this tree’s own logger can be observed, not being reached throughlogging.getLogger()– readsrecord.exc_infooff every such log call and sorts it the wayhandle_p2pitself already does, intoescapedfor a bug andrefusedfor aBTClibException;dispatchre-raisesescapedfirst andrefusedafter, so a crash, a refusal and an acceptance are three outcomes a seed test can tell apart rather than two._built_node’stempfile.mkdtemp()directory is removed withatexit.register(shutil.rmtree, ...), registered the moment it is built: nothing else ever closed or removed it, andtests/fuzz_corpus_test.pycollects into the ordinary suite, so everypytestworker process that ever imported this module was leaving one behind under the real temp directory, forever, on every run.fuzz/corpus/fuzz_process_message/holds one seed per commandp2p.callbacksdispatches, each built from the same fixturestests/unit/p2p/callbacks_test.pyuses.tests/fuzz_corpus_test.pyis extended to exercise them:_resolveloads this one harness by path rather than through the installed package, since it needs no atheris to run and is not reachable through it either, and_REFUSEDreplacesNoneas what a refused seed is marked with, sincedispatch’s own successful return isNoneand reusing it for “refused” would count every accepted seed of this harness as refused instead.fuzz.yml’s own-max_total_timeis now split four ways rather than three, unchanged in total.
An unsolicited block with an unknown header is not a KeyError (closes #711)¶
p2p/callbacks.py’sblockindexes an unrecognised header throughBlockIndex.add_headersbefore looking up the block’s info, rather than readingblock_index.header_dicton a hash nothing had entered there yet. Matching Core’s ownNetMsgType::BLOCKpath (net_processing.cpp, at bitcoin/bitcoin@ca7162cde5): every block runs throughAcceptBlockHeader(validation.cpp, same sha) first, which accepts a header whose parent is already known and refuses one whose parent is not withBLOCK_MISSING_PREV;MaybePunishNodeForBlockthen callsMisbehavingfor that refusal, unlike an unconnectingheadersbatch, which this file already answers by asking for more rather than by discouraging the peer (#233). A block extending a known parent but never separately indexed by a priorheadersround is now accepted and stored instead of raisingKeyError; a block naming a parent this node has never heard of now raisesBTClibValueError, whichp2p/main.py’shandle_p2palready discourages and drops the peer for – where the pre-fixKeyErrorwas neither.
Config(pruned=True) builds a node that actually prunes (closes #601)¶
BlockDB.prune_up_todeletes a block and its reverse patch once more thanMIN_BLOCKS_TO_KEEP(constants.py, Core’s own 288, two days) blocks behind the tip, called frommain._prune_chainafter every forkupdate_chainconnects, which also clearsBlockInfo.downloadedfor the same range, matching Core’s ownBlockManager::PruneOneBlockFileclearingBLOCK_HAVE_DATA/BLOCK_HAVE_UNDOon the index entry it prunes – genesis staysdownloaded=True, since it is never written toblock_dbin the first place.BlockIndex.set_downloadednow checkspendingbefore writing through, the same shapeset_statusalready used (btclib-org/btclib-node#586): a fork longer than the retained depth connects in oneupdate_chaintrial, staging every hash it adds intopendingbefore that trial’s own flush, which a write-through clear reaching one of them would have lost to the nextfinalize. Deletion is by height rather than by file, this store’s own rotation tracking append order rather than a height range the way Core’sFlatFilePosdoes; a.blk/.revfile is unlinked once every block it ever held has been pruned this way,BlockDB.livecounting down to that point.send_versiondropsNODE_NETWORKand keepsNODE_NETWORK_LIMITEDonceConfig.prunedis true, matching Core’s owng_local_servicesgate on!fPruneMode.A
getdatafor a block below a pruned node’s own retained depth disconnects the peer rather than answering silently, matching Core’s ownProcessGetBlockData– fired on height alone, not on whether the block happens to still be on disk.getrawtransaction’s block-hash lookup answers"Block not available (pruned data)"or"Block not available (not fully downloaded)", Core’s ownCheckBlockDataAvailabilitydistinction, in place of the one undifferentiated message both cases used to share.getblockchaininfoanswerspruneheightonceprunedis true.Config.__init__no longer refusespruned=True, andPruningNotImplementedErroris gone along with the refusal.-pruneon the command line keeps Core’s own spelling but not its<n>MiB target: any nonzero value turns pruning on at the fixed retained depth above, the size-target axis being this issue’s own deferred remainder. A negative-prunerefuses to start, Core’s own wording fromnode::ApplyArgsManOptions, rather than silently pruning.
windows-latest gates the suite, and the package claims it (closes #430)¶
test.ymlcarries awindowsjob,windows-latestat3.14alone – not crossed with3.14t, the same axis the interpreter cells argue – gated on the suite passing rather than on the coverage floor theubuntu-latestcells still carry: the floor is a claim about one interpreter on one image, and this cell answers a different question, whether the suite runs at all on a platform nothing in the gate ran before.pyproject.tomlnow carriesOperating System :: Microsoft :: WindowsbesidePOSIXandMacOS, checked by the job above.os-windows.ymlis gone. Its runs priced the job above before it existed; the job now runs what the sentinel ran, so the sentinel is removed rather than kept beside a gate cell duplicating it.REPOSITORY.md’s concurrent-job-ceiling section names the new job and records thattest.yml’s required check, being its own aggregate job rather than any cell by name, needs no ruleset change to cover it.
A NODE_NETWORK_LIMITED peer is skipped for an old block (closes #706)¶
_request_new_block_workskips a candidate for a connection whoseversion_message.servicescarryNODE_NETWORK_LIMITEDwithoutNODE_NETWORK, once it falls more thanMIN_BLOCKS_TO_KEEP - 2behind that connection’s ownbest_known_height, matching Core’s ownFindNextBlocks(net_processing.cpp:1635, at bitcoin/bitcoin@ca7162cde5); a candidate still within reach is offered to such a peer exactly as any other connection’s is.Connection.best_known_heightis this tree’s own stand-in for Core’s chainwork-rankedpindexBestKnownBlock:callbacks.versionsets it from the peer’s ownversion.start_height, andcallbacks.headersraises it off every batch that peer itself sends, this tree keeping no per-peer chainwork-ranked index of its own fordownload.pyto read instead.
The free-threaded cell reports instead of gating (closes #723)¶
test.yml’sfree-threadedjob runs3.14tbesidecoverage’s3.14but is out oftest-passed’s ownneeds:–rocksdicthas never published acp314twheel or an sdist on any release, so the job cannot install this tree’s own store and would otherwise block every merge on a wheel no branch here can ship. Buildingrocksdictfrom its own repository in CI was considered and rejected: it is a Rust crate compiling RocksDB’s own C++ source, and that cost is not owed to a job whose entire purpose is a report nobody is blocked on.REPOSITORY.md’s concurrent-job-ceiling section names the row’s new place, still costing the same slot at the ceiling it always did, no longer gated.
-prune=<n> has a MiB target and a pruneblockchain RPC (closes #705)¶
-prune=<n>forn >= MIN_PRUNE_TARGET_MIB(550) prunes automatically to roughly<n>MiB on disk,Config.prune_target_mibcarrying the target andBlockDB.current_usagesumming every still-tracked.blk/.revfile’s own size, this store’s counterpart of Core’sBlockManager::CalculateCurrentUsage.main._prune_to_targetwalks height by height rather than Core’s own file-by-fileFindFilesToPrune, this store’s own rotation tracking append order rather than a file’s height range, and stops as soon as usage is back under target,MIN_BLOCKS_TO_KEEPbehind the tip never crossed either way.getblockchaininfoanswerssize_on_disk,automatic_pruningandprune_target_sizeaccordingly.-prune=1is manual pruning: nothing is deleted on its own, matching Core’s ownPRUNE_TARGET_MANUAL. The newpruneblockchainRPC deletes block and undo data up to a height or a timestamp, refuses a chain shorter thanChain.prune_after_height– a new field onchains.py’s four leaves, matching Core’s own per-chainnPruneAfterHeight(100000 mainnet, 1000 elsewhere) rather than this tree’s uniformMIN_BLOCKS_TO_KEEP– clamps a height closer thanMIN_BLOCKS_TO_KEEPto the tip rather than refusing it, and answers the height of the last block actually pruned.-prune=<n>for2 <= n < MIN_PRUNE_TARGET_MIBnow refuses to start, Core’s own wording: too small a target to actually run a node on, rather than the fixed 288-block depth every such value used to collapse to.
send_version carries this node’s own tip height (closes #722)¶
send_version’s ownversionmessage carriesNode.best_heightasstart_height, not the literal0every connection sent regardless of how synced this node actually was, matching Core’s ownPushNodeVersion(net_processing.cpp:1673, at bitcoin/bitcoin@ca7162cde5), which fills the same field fromm_best_height.Node.best_heightis a plainint, not a lock-guarded field.main._finalize_forkwrites it onNode’s own thread, once per tip change, andConnection.send_versionreads it cross-thread, onP2pManager’s own asyncio loop – the same freedom from a torn read Core’s ownstd::atomic<int> m_best_height(net_processing.cpp:873) givesPushNodeVersionwithoutcs_main, CPython’s GIL making a single attribute’s read and write each one uninterruptible step.
A peer that cannot serve blocks gets no block work (closes #725)¶
_request_new_block_worknow skips a connection outright when neitherNODE_NETWORKnorNODE_NETWORK_LIMITEDis in itsversion_message.services, matching Core’s ownCanServeBlocksgate (net_processing.cpp:1254, at bitcoin/bitcoin@ca7162cde5), applied at the same point Core applies it, the call site ofFindNextBlocksToDownload(net_processing.cpp:6495). Such a peer used to be offered the whole download window, as though it were archival, because_is_limited_peeralone answers only “which blocks”, not “any at all” – the two questions Core keeps as two separate gates.callbacks.version’s own disconnect for a peer missingNODE_NETWORKnow runs only for an outbound connection, and now acceptsNODE_NETWORK_LIMITEDalone once this node is close to the tip, matching Core’s ownExpectServicesFromConn(net.h:847-856, false forINBOUND,MANUALandFEELERconnections) andGetDesirableServiceFlags(net_processing.cpp:1861-1869,NODE_NETWORK_LIMITED | NODE_WITNESSdesirable once this node’s own tip is within 144 blocks of its best header). This used to testNODE_NETWORKalone on every connection, inbound included, and discouraged and dropped aNODE_NETWORK_LIMITED-only peer this tree’s own new_can_serve_blocksgate above (and Core’s) both consider block-capable – exactly once this node was closest to the tip, the condition under which Core is most willing to keep such a peer.node.status >= NodeStatus.BlockSyncedstands in for “close to the tip”, this tree computing no counterpart to Core’s ownApproximateBestBlockDepth().
The release pull request opens the next cycle (issue btclib-org/.github#528)¶
RELEASING.md’s release pull request retitles the## Unreleasedsections ofCHANGELOG.mdandRELEASE_NOTES.mdand opens the next cycle’s above them, in the one pull request. Opened in a pull request of their own after the release’s, the sections were missing frommainfor as long as that pull request took to land, and a branch landing in between filed its entry under a release it is not in, with nothing to say so. The version bump stays in the pull request that opens the cycle,version-checkcomparing the tag against whatpyproject.tomldeclares. Section 12 of btclib-org/.github’sREADME.mdis the decision; this is its port.
The verbatim files are the standard’s copies, byte for byte¶
.gitattributesstates the union price as section 9 of the standard does (issue btclib-org/.github#423): the driver is a checkout’s and the forge does not apply it, so a pull request whoseCHANGELOG.mdorRELEASE_NOTES.mdoverlaps its base is reportedCONFLICTINGhowever cleanly the pair merges locally, and a rebase on a checkout is what clears it..markdownlint.jsoncpoints at section 14 of the standard for who carries it (issue btclib-org/.github#316), in place of an enumeration of trees.CONTRIBUTING.md’s shared half is btclib-org/.github’s (issue btclib-org/.github#281): the half is replaced whole rather than each change applied by hand, a hand-written list of them being what comes up short. Among them, The landing queue points atREPOSITORY.md’s Plan-gated settings for the ceiling’s figure (issue btclib-org/.github#412).
The suite shuffles after one marker (closes btclib-org/.github#428) (issue btclib-org/.github#426)¶
pytest-randomlyis in theharnessgroup, section 7’s default.pytest.mark.ordersits on one test and holds a place at the head of the queue rather than a sequence, and the two plugins’ own hook order keeps that place under the shuffle;tests/README.mdsays so.test_download’s docstring called it the slowest test in the suite, whichpyproject.toml’s owntimeoutmeasurement refutes, and now says among the slowest.tests/README.mddeclares the property layer absent. Section 7 keys it on the propertyfuzz.ymlclaims for this tree, and #742 is the gap.
REPOSITORY.md’s perimeter is section 11’s (issue btclib-org/.github#550)¶
The scope statement carries section 11’s three limbs in the standard’s own words (issue btclib-org/.github#582). The third – the settings a behaviour the standard describes rests on – is what keeps
.visibilityunder Features, and the topics now that the wiki and the projects board have left it.The opening no longer claims that nothing below is recoverable by reading the repository (issue btclib-org/.github#571). The topics and
.homepagehave a copy inpyproject.toml, which Features, and the topics and Read the Docs already said, so the clause names those two and holds of the rest; this tree is where that contradiction was found.has_wikiandhas_projectsleave the file (issue btclib-org/.github#550). Section 11 puts both outside the perimeter, so the sentence calling each this repository’s own answer goes, and the measurement that the wiki is empty goes with it; the foot names the pair as outside.The
scorecardsentinel’s bar is why the visibility is read back (issue btclib-org/.github#584), section 10 asking a repository that is public and nothing else..forkis read back nowhere, nothing setting it.The ceiling’s section takes the heading section 10 names as its one home, Plan-gated settings, with GitHub’s table beside the plan command (issue btclib-org/.github#569, issue btclib-org/.github#412).
CONTRIBUTING.md’s shared half already pointed there;claude-review.yml’s andcodeql.yml’s headers pointed at the old name and point at the new one now. Secret scanning’s two plan-gated settings stay where the call that answers them is.The Dependabot secret store leaves the facilities nobody reached for (issue btclib-org/.github#572). It is empty because the standard holds that token at the organization, in both stores, which is a decision rather than an absence, and the bullet that already said so for the Actions store reads both back now.
The Free Threading classifier goes (closes #745) (issue btclib-org/.github#577)¶
pyproject.tomldeclares noFree Threadingclassifier. Section 3 of the organization standard declares one where the merge gate exercises the free-threaded build, a gate being what refuses the landing that breaks it, andtest.yml’sfree-threadedjob sits outsidetest-passed’sneeds:. It runs no suite either:rocksdictpublishes nocp314twheel and no sdist, so that job’s sync step fails, a::notice::reports the miss and the suite step behind it is skipped (#746). The classifier would promise a consumer of this package a build nothing here runs. Where that job gates is not reopened by this – #723 and #747 are where it was decided – and the declaration is the half that moves. The classifier returns when a job the gate waits on runs the suite free-threaded; a place in thoseneeds:is not that condition, a job green with its suite step skipped refusing no landing.tests/interpreters_test.pygates that classifier on the interpreters the merge gate waits on (issue btclib-org/.github#577), the biconditional the PyPy classifier already carries over a narrower second side: the PyPy one reads every CI file, where section 3 keys free threading on the gate. That side is theneeds:closure oftest: every job passedand not this workflow’s own text: a job nothing waits on says the build passed somewhere, which is the ground section 3 declines. Comments are dropped before that read, where_namedabove it keeps them on purpose, and what the read leaves is whatdistshows: a setup step naming no interpreter takes the.python-versionpin, and a version a job names counts whatever that job does with it, a step skipped on its own condition included (#750). Both arms of that read take the characters a version is made of and nothing else: an expression otherwise reaches the closure as${{from--python, or whole from between a pair of quotes, and answers its emptiness check with a name that is not one.
local-link-prefix’s comment matches what README.md holds¶
The comment said this tree carried no badge link, and
README.mdcarries a row of them. It now names that row, and says what the row does not settle: every destination on it is absolute, so the pattern’s scheme lookahead decides each of those lines before its badge handling is reached, and the case is unmeasured here for that reason rather than for the absent row the comment claimed (closes #743).
The topics and the keywords are compared by a command¶
REPOSITORY.md’s Features, and the topics carries thediffthat reads the topics andpyproject.toml’skeywordstogether (issue btclib-org/.github#636). It stated the pair and gave no command for it, half being a repository setting and half a tracked file, so the comparison was left to whoever thought to make it by eye. The command is the one sibling copies of this file carry, running here unchanged but for the repository name, and it exits nonzero on a drift. The sentences beside it are theirs too: what an empty right-hand side means, and why both sides are sorted.
A convention name wrapped in its middle is read as it is written (closes btclib-org/.github#651)¶
tests/unit/conventions_test.pycollapses the whitespace of the Not tested here list and then of each name in it.strip()takes whitespace off a name’s ends and leaves what an eighty-column wrap puts in its middle, so a name the break splits matched none of section 7’s and was reported as a convention the declaration invented – a red test on a declaration that reads correctly in the file.tests/README.md’s line here breaks at a semicolon, so nothing was red; what goes is the dependence on where the column falls, which nothing enforces.btclib-benchmarksreads its own line this way, its wrap falling inside a name.
The .gitattributes comment names the driver’s sides and one anchor¶
The comment above the two
merge=unionlines isbtclib-org/.github’s wording (issue btclib-org/.github#646):unionkeepsoursfirst and thentheirs, each of merging and rebasing is named for which side it callsours, and the comment reads the driver as resolving two branches writing an entry at one anchor rather than a bullet appended to one of a few changelog groups.
The tree gains the deps-oldest sentinel, and the badge row two badges¶
README.md’s row carried no badge forpypi-install.yml(closes #740), though the workflow was already here: section 2 of the organization standard ties a sentinel’s badge and its workflow together as one membership, and the row was short of its half. The new line sits afterdeps-latestand beforedeps-oldest, section 10’s calendar order over that stretch, and carries?branch=main, which section 2 has asked of every workflow-status badge since btclib-org/.github#579.deps-oldestwas nowhere in this tree (closes #739): section 10’s Which trees carry which sentinel namesbtclib-nodefor it, and nothing here ran the workflow or showed its badge..github/workflows/deps-oldest.ymlrunsuv lock --resolution lowest-directonrequires-python’s own floor and then the suite, the mirror ofdeps-latest.yml’s--upgrade; its badge sits afterpypi-installand beforeos-macos, the calendar order again, with the same?branch=main. Unlikedeps-latest.yml, this workflow carries no lint job: that job exists there because a new mypy release can add a check the codebase fails before the next re-lock catches it, and a floor resolution moves mypy to a release already behind rather than one ahead, which is not a claimpyproject.toml’s dependencies make to an installer.btclibis the one direct dependency the new job cannot take to a floor:[tool.uv.sources]points it at the git branchmainrather than at a registry range, so the specifier beside it is a claim to an installer and not to this checkout, and the resolution moves it to that branch’s tip as--upgradedoes rather than to the floor. What it verifies at a floor is every specifier uv resolves from a registry:rocksdictamong the runtime dependencies, and the[dependency-groups]entries the suite step installs.No repository of the organization carried
.github/workflows/deps-oldest.ymlbefore this branch, measured across the organization by HTTP status line withdeps-latest.ymlas the positive control. The debt is carried until the first tree schedules the workflow, and this is that tree (issue btclib-org/.github#323).
v2026.8.27¶
A functional test waits for the status it is about (closes #525)¶
test_a_slow_manager_start_cannot_still_clobber_the_status_it_racedwaits onnode.status, in place of waiting on the chain length and sampling the status after it (closes #525): the two are separate writes on the node’s own loop thread –update_chaincommits the fork, andfinish_synca few statements later moves the status – so the chain reaching its new length says nothing about whether the status has moved yet. The window is narrow and real on every interpreter; it was hit on run 33092703719, the free-threaded job of the release pull request, with the suite otherwise green and coverage at 100%.The wait still fails where the status is genuinely clobbered (closes #525), which is the regression #398 put the test there to catch: a clobber leaves the status below
HeaderSynced,_ready_forkreturns at its own first guard,finish_syncis never reached again and the wait runs out. What changes is the failure’s shape, aWaitTimeoutErrorin place of anAssertionError, not what makes it fail.
The retry wraps the resolution, not a probe of it (closes #548)¶
Both publish jobs retry the
uv runitself, with--refresh, in place of #546’scurlpoll followed by a single attempt (closes #548): the poll and the install were not served the same index. On run 33091369402 the wait reportedthe index serves 2026.8.dev401and uv failed on that version one second later. The file existed throughout – its project page and JSON API both answered200– and the simple API disagreed with itself by request: a plain GET still served a stale list minutes afterwards, while the same GET withCache-Control: no-cacheand a cachebust served the new version three times out of three.What that cost was a check whose subject was not the operation it guarded (closes #548): the step needs to know whether uv can resolve the version, and a
curlGET answers a different question that can differ. Retrying the real command subsumes the probe, so the probe is gone rather than kept beside it – a second check that can disagree with the first is a second thing to reason about.--refreshis the other half: the CDN is not the only cache in the path, uv keeping its own copy of an index response. Verified against the exact version the runner failed on, which resolves and imports with--refresh.
The install check waits for the index (closes #546)¶
Both publish jobs poll the simple API for the version they just uploaded before installing it, twenty attempts fifteen seconds apart, erroring by name if it never arrives (closes #546): the index does not serve a new file the instant the upload returns, and the step that installs from it ran 1.2 seconds later. On run 33089825557 TestPyPI answered
200 OKfor both files and printed the project URL; the resolver was told there was no such version; the index served it minutes afterwards.publish-pypicarried the identical shape, which on a tag push is the third distinct way these three lines produce a published-but-unattested release with no GitHub release page (#541, #543).The wait asks the simple API, not the JSON one (closes #546): the resolver reads the simple API, and #545 is the finding that JSON is a cache of the index where simple is its state – a wait in front of a resolver asks what the resolver asks.
btclib-org/btclib’s ownpypi-install.ymlcarries the same wait against a failure this tree cannot have, its step installing whatever the resolver picks so that starting early tests the previous version and reports a pass for it. The==$versionhere makes that a loud failure rather than a quiet wrong answer, and the wait makes it neither. The match is exact on the quoted version, checked against the live index for a version present, one absent, and a prefix of a present one.
The install check names its interpreter (closes #543)¶
Both publish jobs, and
RELEASING.md’s two install checks, pass--python 3.14to theuv runthat installs the published package and importsNode(closes #543):requires-pythonhere is>=3.14andubuntu-latestdefaults to 3.12, so uv resolved against an interpreter the published package excludes and reported the requirement unsatisfiable – in a step that runs after the upload. On a tag push that is the shape #541 described: a version on PyPI, a filename that index never accepts twice, andattestandgithub-releaseskipped behind the failure.The file was already internally inconsistent about it (closes #543): Rebuild a release from its tag passes
--python 3.14in both of its commands, and the two install checks did not – which is to say the two commands that lacked it were exactly the two nobody had ever run. Measured on run 33070323112, the rehearsal #542’s own fix first let reach this step, and the fix verified against TestPyPI’s2026.8.dev201– the artifact that run uploaded before dying – by running the failing command locally at 3.12, where it fails identically, and at 3.14, where it imports.
The send bound is derived from the peak its pacing checks reach (closes #521)¶
The two pacing mechanisms’ overshoots do not add, so
MAX_QUEUED_SEND_BYTES(p2p/connection.py) is not their sum:advance_getdataandadvance_cfilters(p2p/callbacks.py) both pace onConnection.queued_send_bytesand neither reads anything else, so filters a connection already owes leave agetdataanswer that much less room rather than adding to what that answer may commit; andMAX_CFILTERS_INFLIGHT_BYTESbeing the lower of the two bounds,advance_cfiltersstops on its first check throughout agetdataovershoot. Agetcfilterspipelined behind agetdatathe peer has not drained is counted inside that answer’s own peak rather than on top of it. The bound is written asMAX_GETDATA_INFLIGHT_BYTESand one block, and room above them; its value is unchanged, that bound being twiceMAX_PROTOCOL_MESSAGE_LENGTH.What the room above that peak is for is written down: a sender that passes no pacing check commits its whole message on top of whatever the field already holds — the
notfoundclosing agetdataanswer, a transaction announcement’sinv, aheaders, anaddr— and this room does not hold the largest of them, so a peer that has stopped draining can be dropped by a message no pacing check stands in front of, where the pacing bound would have paused an answer instead. Issue #529 is where that is measured and where pacing those senders is decided; raising this bound is not the answer to it, because_send_due_announcements(download.py) sends as manyMAX_INV_SZchunks in one pass as the mempool has entries to announce.tests/unit/p2p/connection_test.pymeasures the displacement rather than assuming it: agetdataanswered on a connection that already owes a filter answer serves fewer blocks and carries no larger a total, driven through the real dispatch; and the bound is held aboveMAX_GETDATA_INFLIGHT_BYTESand one whole block message, the wire envelope measured off aMessagebuilt the wayConnection._queuebuilds one. The boundary tests over that comparison say that is what they are, rather than that they are the maximum either mechanism reaches.
Both publish jobs set up uv before running it (closes #541)¶
publish-testpypiandpublish-pypieach gain aSetup uvstep, ahead of the step that installs the published package from the index and importsNode(closes #541): neither job had one, and the runner carries no uv. Measured rather than reasoned – the first TestPyPI rehearsal (run 33067874355) uploaded successfully and then exited127onuv: command not found. On a tag pushpublish-pypihas the identical shape, so the release would have put a version on PyPI – a filename that index never accepts twice – and then failed, takingattestandgithub-releasedown as skipped behind it: published, unattested, with no release page and no bill of materials, and recoverable only by yanking and cutting a patch version. These two jobs had never executed a step before today,release.ymlhaving landed with #503 and nothing ever having been published here, so every belief about them was inference from reading the file.The inline post-publish check says why it is inline (closes #541):
btclib,btclib-secp256k1andbitcoin-core-rpceach put theirs in apypi-install.ymlof its own, and each provisions a toolchain explicitly there. That file reads the index, so it has nothing to install until a release exists, which is the case this repository is in; btclib-org/.github#488 is where the standard’s own silence on the question is being settled, and #502 is where moving it becomes possible. Without the comment the next reader moves this onto the three-tree shape and loses what the inline step was for.
RELEASING.md names public-api, and audits the run (closes #538)¶
A What a red
public-apimeans section, and a numbered post-tag step that audits the run job by job (closes #538): the job has been inrelease.ymlsince #503 and the procedure never named it, though it is the one job in the pipeline designed to exit 1 on an ordinary cycle – any public-API difference since the last release. What the section says is that a red one is read rather than obeyed, each finding checked by hand againstRELEASE_NOTES.md’s own Breaking changes list, the question being whether a break is announced rather than whether it exists.The audit step looks for
skippedwith zero steps, not for red (closes #538): a failed job is loud and a skipped one is silent.btclib-org/btclib’s ownv2026.8.27published with its post-publish sentinel never having run and the run reading as done (btclib-org/btclib#1470, btclib-org/.github#484). The step carries thegh apicall that lists every job with its step count, and names the one skip that is correct and would otherwise be cited as the defect:publish-testpypion a tag push, its guard beingworkflow_dispatch. Neither gap can bite this repository’s first release –public-apiresolves no previous tag and cannot fail, and there is no post-publish job here yet (#502) – which is the argument for writing both now rather than after: a post-tag audit that lands after the tag documents a check nobody ran on the release it was written for.
A reorg reaches this node from a real bitcoind (closes #513)¶
tests/integration/reorg_test.pysubmits a competing branch to the regtest bitcoind the node is already synced against, and holds the node to the tip Core switches to (closes #513):update_chainand_reconcile_mempool_for_reorg(btclib_node/main.py) are otherwise driven by tests that hand this node both branches directly, on the thread that built them, which says nothing about a reorg arriving over p2p from an implementation this tree did not write. A reorg is where the block index, the UTXO set, the filter index and the mempool have to move backwards together, and where a disagreement with Core is a chain split rather than a slow peer.The reorg is asserted and not assumed: the node is held to the abandoned branch’s own tip before the competing branch is built at all, the abandoned block is looked up in the block index afterwards and found off the active chain at
BlockStatus.valid, and the transaction that branch confirmed is looked for in the mempool. Stubbing_reconcile_mempool_for_reorgout leaves the first two passing and fails the third, which is what says the mempool half is carried by that function rather than by the sync.The mempool is waited for where the block index is read straight off:
update_chaincommits the new tip in_finalize_forkand reconciles the mempool only after it returns, so the wait that sees the tip move can still see the transaction outside the mempool. Delaying_reconcile_mempool_for_reorgfails a bare read there and leaves the wait passing, which is what says the order is the node’s and not the test’s timing.The chain is dated backwards from the clock rather than from the regtest genesis
backpressure_test.pycounts from: Core relays no inventory while it holds itself to be in initial block download (PeerManagerImpl::UpdatedBlockTip,src/net_processing.cpp) and leaves that state only once its own tip is withinDEFAULT_MAX_TIP_AGEof the clock (src/kernel/chainstatemanager_opts.h), so a chain dated from the genesis is one Core accepts and never announces. The modules that sync rather than wait to be told do not notice.The branch carries a transaction, which costs the chain Core’s own coinbase maturity: the only thing a chain built from nothing has to spend is a coinbase, and
COINBASE_MATURITY(src/consensus/consensus.h) is a constant rather than a chain parameter, so regtest does not relax it. What the blocks cost at that height is nearly nothing, each being a coinbase and at most one other transaction: both branches together cost less than the megabyte chainbackpressure_test.pyhands over, which is why this module is in the gate’s own workflow rather than reserved for a sentinel.
The two publish jobs stop gating on public-api’s result (closes #534)¶
Both jobs’
if:now opens withalways()and readsneeds.test.result,needs.lint.resultandneeds.docs.resultexplicitly,public-apistaying inneeds:for ordering only (closes #534):public-api’s own comment calls it deliberately not a merge gate, exiting 1 on any public-API difference since the last release with no matchingRELEASE_NOTES.mdentry – its documented behaviour on a real breaking-changes cycle, not a crash. A bareneeds:gates on every listed job regardless of why it failed, a skipped job counting the same as a failed one, so that designed failure would have kept both publish jobs from ever starting.btclib-org/btclib#1461is where the identical shape did exactly that, on the first cycle to run the job against a real breaking change; this repository’s ownrelease.ymlcarried the same bug and had never runpublic-apiagainst one, no release having been cut yet.attestandgithub-release, two jobs further down the same file, already carried this pattern correctly.
The btclib floor carries p2p.negotiation (closes #381)¶
btclib[secp256k1]>=2026.8.27in place of>=2026.8.21, andtest.yml’sdistjob installs the built wheel with no override and importsNode, in place of--no-depsand a metadata-only read (closes #381):src/btclib_node/download.pyimportsbtclib.p2p.negotiation.FeeFilterunconditionally, and no PyPI release before2026.8.27carried that module, so an ordinarypip install btclib-nodecould not resolve regardless of the floor declared — the smoke test was narrowed to what it could honestly assert without a released btclib rather than block on a gap release machinery could not close. Verified against the index rather than assumed: installing2026.8.27in isolation and importing every btclib modulesrc/uses found none missing, and installing the built wheel the same way and importingNodesucceeded.
A bare run collects the integration directory (closes #508)¶
tests/integrationis atestpathsentry (closes #508): section 7 of the organization standard puts every suite directory there, so that a bareuv run pytestis still the whole suite, and attaches to an integration directory the conditions that make that affordable – each test skipping itself where the environment switch that asks for it is unset, the switch named in the skip message, and the directory kept out of the coverage ratchet.tests/integration/conftest.pyand[tool.coverage.run]’somitalready held all of those, so the entry was what was left. What a contributor without a bitcoind sees isset BTCLIB_NODE_INTEGRATION=1 to run the integration testsagainst each test there, rather than a directory a bare run never mentions – and not the divergence from the standard the alternative would have owed an issue.The skips do not cost the run its green: the summary bar stays green with the skipped count sitting beside the passed one, and the run exits 0. That is the whole of what collecting the directory costs.
integration-bitcoind.yml’s own invocation is a selection, so the floor does not apply to it: the workflow namestests/integrationalone, which leaves the rest oftestpathsout, andtests/conftest.py’srelax_coverage_floordrops the threshold for it. Asking for--cov-fail-under=100on that same command exits 1 where the command the workflow runs exits 0, which is what says the drop is what carries it rather than an absent floor.
A peer’s malformed reject is refused as the peer’s own fault (closes #515)¶
Reject.parserefuses a payload withInvalidRejectPayloadError, aBTClibValueError:handle_p2p(p2p/main.py) discourages the peer where the exception is aBTClibExceptionand reads anything else as this node’s own code failing on content that was fine, so amessageor areasonno utf-8 decodes and a code outside BIP61’s own tables belong inside that family.WrongNetworkMagicErroris the same decision one layer out, over the envelope rather than the payload, and its docstring already carries the reasoning.parseaccepts exactly whatserializewrites: every field is held to the length its own prefix declares,BytesIO.readanswering a stream that has run out with what is left rather than with an error, and what followsreasonis either the 32 octets of a hash or nothing at all — BIP61 ending a version reject after the common payload and having a tx or block reject append the hash of what was rejected. A payload cut short mid-field, a hash cut short, and octets past a whole one are each refused rather than parsed into an object the peer did not send.fuzz/fuzz_reject.pysuppressesBTClibExceptionalone: that being the whole of what the parser refuses an input with, an input it refuses any other way leaves the harness, which is what givesfuzz.yml’s scheduled row something to report.
The fuzz sentinel, over the parser a peer reaches first (closes #402)¶
fuzz/fuzz_reject.pyfuzzes BIP61’srejectpayload parser, andfuzz.ymlruns it on the calendar row section 10 of btclib-org/.github’s README givesfuzz: the property that section keys the sentinel on is that nobody stands between a parser and an adversary choosing the octets, andp2p.callbacks.rejectreads what a peer sent with no verification of any kind in front of it.Reject.parseis what this tree owns of that surface: the rest of what a peer’s octets reach here isbtclib’s codec, fuzzed by that repository’s own harnesses, or a method over a connection that owns a socket, a manager and a node — which is issue #516 and not this change.Atheris runs as an ordinary script rather than under ClusterFuzzLite, which is what
btclibruns and would otherwise be the port: that toolchain builds targets insidegcr.io/oss-fuzz-base/base-builder-python, whose interpreter is theENV PYTHON_VERSION 3.11.13of google/oss-fuzz’s owninfra/base-images/base-builder/Dockerfile, whererequires-pythonhere is>=3.14— so thepip3 install .such a build begins with is refused before a target is compiled. Atheris itself admits this tree: thefuzzdependency group resolves its cp314 manylinux wheel, under the platform marker its own wheel list forces.The seeds are a starting point and not a regression suite:
tests/fuzz_corpus_test.pyholds every file underfuzz/corpus/to parsing and reserializing to itself, and says why an input a crash was found on belongs in the ordinary suite instead — a hardened parser refuses it, which is the opposite of what that module asserts. What the test also asks is that a harness’s declared entry point still resolves, so a harness aimed at a name this tree has renamed fails on the pull request that renamed it rather than on the sentinel’s day.Reject.parserefuses a peer’s own malformed payload withValueErrorwherehandle_p2psorts a peer’s fault from this node’s byBTClibException(issue #515): the harness suppresses both families and cites that issue, which is what keeps the sentinel about the crashes nobody has described rather than about a classification already known and filed.
A pacing check counts what it has handed over (closes #512)¶
Connection.sendframes a message and counts it againstqueued_send_byteson the calling thread, and schedules only the write (closes #512):advance_getdataandadvance_cfilters(p2p/callbacks.py) pace an answer by reading that field between two items, and both run onNode’s thread. Counted where the write happens instead, the field says nothing about the items the same loop has just handed over, so agetdataanswer runs as far pastMAX_GETDATA_INFLIGHT_BYTESas the loop is behind — far enough, for blocks of the size a peer in initial block download asks for, to spend the roomMAX_QUEUED_SEND_BYTESleaves above that bound and reach the drop. A peer merely slow to drain is dropped that way for asking for the blocks this node asks its own peers for, which is the outcomeMAX_QUEUED_RECV_BYTES’s own comment names as the wrong one for a flood-control case. Counted at the hand-off, the roomMAX_QUEUED_SEND_BYTESleaves above each pacing bound holds what a check made before its own send can put past it, which is what that bound’s own comment already says it is sized for.Serializing a message is the caller’s cost rather than the loop’s (closes #512): the thread that asks for a block has already parsed that block out of
block_dbto build the payload, and how much of that it does in one pass is what the pacing bound bounds. The asyncio loop, shared by every connection, no longer serializes a block between two socket reads.queued_send_bytesis guarded by a lock, the wayqueued_recv_bytesalready is (closes #512): bothNode’s thread andP2pManager’s reachConnection.send, and the write’s own completion decrements from the loop. The two directions carry counters of one shape — each incremented by the thread that hands the work over, before it is offered anywhere else — so a reader who has met one has met the other. What differs is only the granularity each is checked at: a send is weighed one message at a time, whereparse_messagesaccumulates a whole pass and weighs it once at the end.
Every backpressure bound is watched doing its job (closes #490, #492)¶
tests/functional/p2p/backpressure_test.pydrives the send-side bounds against a peer on a real socket that completes the handshake and then never reads again (closes #492): agetdatalarger than the send queue leaves the rest onnode.pending_getdatawith the connection stillConnected, which is what saysMAX_GETDATA_INFLIGHT_BYTESengaged —advance_getdatahas no other way out with items still to serve; agetcfiltersreaching a connection already that far behind is parked whole againstMAX_CFILTERS_INFLIGHT_BYTES; and what reachesConnection.sendwith no pacing point in front of it is refused atMAX_QUEUED_SEND_BYTES, the connection stopped andqueued_send_bytesnever past the bound. A bitcoind cannot be the peer for any of the three, a well-behaved daemon always reading, so this half wants a synthetic peer and no daemon at all.tests/integration/backpressure_test.pyputs a real bitcoind behindMAX_QUEUED_RECV_BYTES, serving megabyte blocks faster than this node validates them (closes #492): the pause is counted on the loop’s own thread, by an event that records being cleared, rather than by a poll that would have to catch a pause lasting milliseconds — and the node still reaches bitcoind’s own tip, which is what separates a pause from a stall. Its chain is built in the test and handed to bitcoind throughsubmitblock: what a wallet can put in one block is bounded by mempool policy, so a megabyte of it costs hundreds of transactions and a coinbase maturity to fund them, where a coinbase paying one large unspendable output is a megabyte on its own and needs no policy relaxed on the command line.integration-bitcoind.yml’s header names the second question the directory now asks. The initial-block-download rehearsal the issue sketched around this one – a reorg announced to this node by Core rather than handed to it in process – is its own question and is filed as one (issue #513).Both halves run in the gate rather than on a weekly sentinel (closes #492): the issue reserved that question and argued the other way, on the ground that reaching bounds this size means mining regtest blocks and moving tens of megabytes. Built rather than mined, the fixtures cost seconds, so the cadence a sentinel buys is not worth the delay it also buys — a bound that stops engaging is found by the pull request that broke it rather than by the following Sunday. Nothing in the weekly calendar changes, and neither does which workflows this tree owes.
Node._drain_message_queues’s docstring says what one shared queue costs a connection paused onMAX_QUEUED_RECV_BYTES, andtests/unit/init_test.pymeasures it in passes of that loop (closes #490): the items another peer has ahead of a paused connection grow with the number of peers currently busy, while the share popped per pass grows only with the log of the whole backlog, so the wait is a function of how many peers are busy rather than the constant Core’s own per-peer round gives — but it grows more slowly than their number, and it is bounded, each connection’s own contribution being capped by that same bound. That is the measurementMAX_QUEUED_RECV_BYTES’s own comment said it was deciding without, and it does not ask for a bound of the paused connection’s own.
The two publishing environments exist, and the files say so (closes #509)¶
REPOSITORY.mdgains The two publishing environments, recording thepypiandtestpypipair by the calls that read it back, andRELEASING.md’s One-time setup andCONTRIBUTING.md’s A release path, and nothing published on it yet stop naming the pair as missing (closes #509): both were created withfametranoas the required reviewer and self-review left on — the maintainer who pushes the tag is the reviewer — andpypirestricted tov*tags. What the new section argues rather than states is why the count is read back at all: an environment a workflow names and the settings do not carry is created by GitHub at the first deployment that references it, with no protection rules, so the pair being absent would not have failed a release for want of a gate — it would have published without asking anybody.REPOSITORY.md’s What is not configured, and why keeps only what is still not configured, which is the release itself, andRELEASING.md’s cross-reference to that bullet now uses the title the bullet carries.REPOSITORY.md’s Token permissions names the four jobs ofrelease.ymlthat elevate pastcontents: read(closes #509): the section ended “Nothing here publishes, attests, or writes to the repository’s contents”, which that workflow has contradicted since it landed —publish-pypiandpublish-testpypiholdid-token: write,attestholdsattestations: writebeside its own, andgithub-releaseholdscontents: write.
The declared version is a calendar version (closes #504)¶
pyproject.tomldeclares2026.8, the shapeRELEASING.md’s calendar scheme takes between releases, in place of the0.1.0that predates this tree’s release path (closes #504):0.1.0is a version this repository has already tagged — a lightweight tag from 2023 with a release page of its own and nothing published from it — so a checkout ofmainreported itself as the prototype that tag names, whichrelease.yml’s ownpublic-apijob already reads as not a release, excludingv0.1.0there by name. The month alone cannot be mistaken for a release either:version-checkrefuses a two-component version on a tag. What the first release does to the version is now add the day, rather than adopt a scheme for the first time on the one day every step ofRELEASING.mdis also running for the first time, ending in an upload that cannot be undone.RELEASING.md,CONTRIBUTING.mdandRELEASE_NOTES.mdno longer say the declared version is0.1.0, anduv.lockcarries the project version too, so it is re-locked. Nothing is published by any of this: both this file andRELEASE_NOTES.mdstill open under## Unreleased, and there is still no release.
codeql.yml runs on a pull request and reports one context (issue #402)¶
codeql.ymlgains thepull_requesttrigger and an aggregate job namedcodeql: every job passed(issue #402, under btclib-org/.github#349 and btclib-org/.github#459): the analysis ran onmainafter a merge, on a commit that is already the default branch, and theanalyzematrix produced a context per language, so no branch rule had a single stable name to hold. The two land together because either alone is inert — an aggregate with no pull-request trigger produces no context on the run a pull request has, which is what btclib-org/bitcoin-core-rpc#233 was about, and the shape here is ported from that tree’sdee71fe8. The header paragraph arguing against the trigger goes with it, as does the concurrency comment readinggithub.refas the whole of what groups a run; the group is now the pull request’s own number where there is one.REPOSITORY.mddropscodeql.ymlfrom the workflows that must not become required checks, and thecontextsarray still does not name it: requiring the check is a repository setting and no part of this change.
The interpreter declarations are compared (issue btclib-org/.github#365)¶
tests/interpreters_test.pycomparesrequires-python, the per-version classifiers,.python-versionand every interpreter a workflow or composite action names (issue btclib-org/.github#365): section 15 of the organization standard asks a library for the module that keeps its own declarations in step, and this is ported frombtclib’s copy. It reads every CI file rather than the platform sweeps alone, this tree’s window being one version wide and written out literally in each of them, and it parsespyproject.tomlwithtomllibwhere the sibling reads it with a regex — that sibling’s floor being below the versiontomllibarrives in and this tree’s not.[tool.pytest.ini_options] testpathsnames the module, which sits besidetests/unitandtests/functionalrather than under either, and[tool.coverage.run] sourcecovers all oftests/, so a module nothing collects is a file the floor reports at zero.The window itself is unchanged: the standard gives a tier-1 repository the library window and this tree declares an application’s, which is the first of that issue’s boxes and is issue #507 here — the package does not import below 3.14, PEP 649’s lazy annotations being what lets its modules annotate with
TYPE_CHECKING-only names, so the floor moves with the work that makes the claim true rather than ahead of it.
The lint gate selects every family ruff ships (issue #402)¶
[tool.ruff.lint]selectsALL, with every declined rule inignorecarrying its reason (issue #402, under btclib-org/.github#334): a hand-picked list rots, whereALLtakes a new family in on the pull request that bumps ruff’s own pinned rev, which is section 5 of the organization standard. What the switch surfaced is answered where it sits: the formatter-conflict rules inignorewith the vendor’s citation, whiteboxSLF001/EM101undertests/**inper-file-ignores,INP001for the entry-point directories nothing imports, and a# noqawith its reason at eachsrc/btclib_node/site that reaches another object’s private state on purpose.[tool.ruff.lint.pydocstyle]declaresconvention = "pep257", the btclib-node half of btclib-org/.github#177, bbt’s half still owed there: the convention settles the two rule pairs ruff warns about as incompatible, and it turnsdocstring-starts-with-thisoff — a rule the select list had named on its own, and one the tree has no finding under either way.tests/functional/andtests/unit/p2p/are packages: each was the one directory on its path without an__init__.py, so the modules under them read as an implicit namespace package (INP001) while their siblings did not.rpc/errors.py’stype_errordocstring is raw: it quotes Core’s own"Wrong type passed:\n%s"format string, and a raw docstring carries that backslash without an escape sequence (D301).
Badges, a scorecard sentinel, and a release-path API check (issue #402)¶
README.mdopens with the badge row section 2 of the organization standard derives from what the tree is (issue #402, under btclib-org/.github#338): the index and forge badges, the gate workflows and pre-commit.ci, then one badge per sentinel in section 10’s calendar order. The index badges answer “not found” until a first release reaches PyPI, which is datable rather than a defect; there is no Read the Docs badge, no project there being connected (REPOSITORY.md)..github/workflows/scorecard.ymlruns the OpenSSF Scorecard weekly (issue #402, under btclib-org/.github#339): Saturday hour 03 at this repository’s minute, ported from btclib’s copy, its triggers the action’s own rather than section 10’s general rule.release.ymlgains apublic-apijob:griffe checkagainst the previous releasedv*tag (issue #402, under btclib-org/.github#326): both publish jobs need it, so a break in the public surface is refused whileRELEASE_NOTES.mdis being written rather than found by a caller.v0.1.0is excluded as the prototype tag it is, so the first release resolves no previous tag and the check skips itself, the workflow’s own comment carrying the reason. The header sentence claimingintegration-bitcoind.ymlis not in this tree goes with it, that workflow existing and gating every pull request as a required check.
REVIEWING.md converges with the organization’s copy (issue #402)¶
The shared half of
REVIEWING.md— everything above## This repository in particular— is byte for byte btclib-org/.github’s (issue #402, under btclib-org/.github#353): the wording this copy held predates the ack of record becoming a forge review, and convergence rather than the one measured sentence is what also covers the drift nobody measured.
asks_for_everything resolves testpaths like file_or_dir (closes #496)¶
tests/conftest.py’sasks_for_everythingnow resolveswanted, built fromtestpaths, the same way it already resolvedgiven(closes #496):config.rootpathis built withos.path.abspath, which leaves a symlink in the path alone, wherePath.resolveon the command-line paths follows one, so a rootdir reached through a symlink made the two sides incomparable, read the whole suite as a subset of itself, and relaxed the coverage floor on the run it exists to gate.tests/unit/coverage_floor_test.pypins the case through a real symlinked directory built intmp_path.
__all__ covers the package, and the public surface is tested (closes #497)¶
Every module and package under
src/btclib_node/now declares__all__, andtests/unit/all_test.pyasserts nothing public is missing from one (closes #497):py.typedships and this package is published, so section 7 of the organization standard does not let its escape clause reach the public-surface bullet the way it reaches the other seven, and nothing before this walked the tree to check that every module declares its own list.tests/README.md’s table and its “Not tested here” line move the public surface into what is declared tested, andtests/unit/all_test.pyis ported frombtclib’s owntests/all_test.py, named as the precedent it takes its shape from.
tests/README.md declares section 7’s convention-test bullets (closes #488)¶
tests/README.mdnames which of section 7’s convention-test bullets this repository tests and the module that tests each, andtests/unit/conventions_test.pyasserts the declaration is true (closes #488): section 7 of the organization standard says which of these bullets a repository implements is declared, not inferred, and an absent declaration made an absent convention test indistinguishable from a convention this tree does not have.
A mutation from outside the runner never reaches a worker (closes #477)¶
CLAUDE.md’s Non-obvious facts names two more ways a green pytest run means something other than it appears to (closes #477): a mutation applied by monkeypatching an attribute in the controlling process, before calling
pytest.main, never reaches the worker subprocess-n autoruns the test in, and the guarded test passes against the original, unmutated code; editing the file and reverting it afterward is the form that reaches a worker, since a worker reads the file. A.venvreused from another worktree carries the same hazard from a different mechanism: itsbtclib_node.pthis a plain absolute path fixed atuv synctime, so an interpreter run from a differentcwdstill imports the other worktree’ssrc/, unmutated.REVIEWING.md’s This repository in particular asks a reviewer to check which form a mutation took, andCONTRIBUTING.md’s Mutation testing notes thatcosmic-ray’s own sweep already takes the form that works, writing each mutation into the file rather than into a running process.
CLAUDE.md says what pytest collects (closes #483)¶
The
python_filesbullet named an overridepyproject.tomldoes not carry (closes #483): collection undertests/unitandtests/functionalfollows pytest’s own default (test_*.py,*_test.py).
A repeat version ahead of verack is ignored, not answered again¶
callbacks.versionnow ignores a secondversionfrom the same peer ahead of its ownverack, andConnection.parse_messagesnow paces a handshake command’s own wire size the same way it already paced an ordinary message’s (closes #482):handle_p2p_handshakenever moved a connection’s ownstatusoffOpenon a repeat, so a peer withholdingverackcould resendversionas fast as this node’s read loop parsed it, each one answered again withWtxidRelay,SendAddrV2andVerackand queued ontohandshake_messageswith nothing pausing that connection’s own reads. Core’s own guard,pfrom.nVersion != 0(net_processing.cpp, at bitcoin/bitcoin@5f45583e43), ignores a repeat outright instead – no reply, no discouragement – which is what this callback now does too, andhandshake_messagesnow sharesMAX_QUEUED_RECV_BYTESwithmessages: the earlier fix (#462) scoped that pacing away from this queue on the reasoning that a full drain every pass bounds how long a backlog persists, which says nothing about how large one pass’s own backlog can grow before it drains.
P2pManager.messages/handshake_messages name why an unlocked deque is safe¶
The comment beside
P2pManager.messages/handshake_messagesnow names the mechanism that makes appending from one thread and popping from another safe with neither locked (closes #484):deque.append,.appendleftand.popleftare each wrapped in their ownPy_BEGIN_CRITICAL_SECTION/Py_END_CRITICAL_SECTION(Modules/_collectionsmodule.cand its clinic-generated wrapper, at python/cpython@f54fd2ab6e) – CPython’s own per-object lock under a free-threaded build, and a no-op under the ordinary GIL one (Include/critical_section.h’s own “no-ops in non-free-threaded builds”). The only argument on record before this was an empirical stress test in a coder’s own report to the human, never landed prose.
The typos hook is repo: local, pinned through additional_dependencies¶
.pre-commit-config.yaml’styposentry no longer mirrorscrate-ci/typos(closes btclib-org/.github#399):localandmetaare the only tworepo:valuespre-commit autoupdatefilters out before it walks a config’srepos:list, so a mirroredtyposentry is what a scheduled autoupdate can move ontocrate-ci/typos’s own movingv1alias, past thepinned-revguard that only readsrev:. Therepo: localhook carries the pin itself, inadditional_dependencies: [typos==1.49.0], andlanguage,entry,argsandtypesare upstream’s own typos hook definition, copied in rather than fetched —argswas already named explicitly in the mirrored entry, for the reason its own comment gave, and the rest the mirror took from the manifest. The block carriesstages: [pre-commit, pre-merge-commit, pre-push, manual]too: arepo: localhook inherits no stage restriction from a manifest, so leavingstages:out would run the hook atcommit-msgtoo, where its own--write-changesrewrites the commit message being typed.
.gitignore’s docs/_build/ entry is gone¶
build/already coversdocs/build/html, the directoryCONTRIBUTING.md’s documentedsphinx-buildcommand writes (closes btclib-org/.github#411):docs/_build/is the stock GitHub Python template’s Sphinx section, and no command in this repository writes there —docs/carries noMakefileormake.batof its own, sphinx’s own default output directory being reachable only by a commandCONTRIBUTING.mddoes not name.
Regtest against Bitcoin Core is a required check on main¶
integration-bitcoind.yml’s own job now blocks a merge rather than only reporting: the workflow landed with #374 gating every pull request, but whether a red run stops anything is a repository setting and not a file, so it needed thegh apiPATCHREPOSITORY.mdalready carried as its own follow-up. The context is the job’sname:, held outside the tree as a literal string nothing here can keep in step, so that file now says what renaming the job would cost and in which order the two changes go.CONTRIBUTING.md’s What gates a merge, and what only reports named three required checks and the workflow’s own header still said making it gate a merge was a change for later: both are the same fact recorded in a second place, and both are corrected here.
A Bitcoin Core citation reads at bitcoin/bitcoin@<sha> (closes #471)¶
An identifier directly followed by a parenthesised list ending in
bitcoin/bitcoin@<sha>parses as a Python call, which is what ruff’sERA001reads as commented-out code (closes #471): every citation in a#comment acrosssrc/andtests/now hasatglued to the sha on the same physical line, which cannot parse for a structural reason rather than an accidental one –atand the citation’s own leading word are two consecutive names with no operator between them, whatever text surrounds them or however the comment wraps. A citation inside a docstring needs none of this, sinceERA001only ever walks comment ranges.CLAUDE.md’s Following Bitcoin Core states the shape.
The self-connect nonce moves onto the connection, off the ring (closes #448)¶
callbacks.versionnow asksP2pManager.is_self_connect_nonce, which walkspending_outbound_nonces– a set that shrinks exactly as an outbound connection completes its handshake or closes – in place ofmanager.nonces’s own fixed-size ring (closes #448): the ring was a process-wide list of the ten most recently sent nonces, so a burst of outbound connects could evict a still-outstanding attempt’s own nonce before itsversioncame back, a false negative on a genuine self-connection.Connection.send_versionnow records the nonce it drew on the connection itself and, only for an outbound connection, in that set throughadd_pending_outbound_nonce;P2pManager.promote_connectionandremove_connectioneach discard their own connection’s entry once it leaves the handshake, both inside the same_connections_lockevery other access to the set takes. This is closer to Core’s ownCConnman::CheckIncomingNonce(net.cpp:360-376at bitcoin/bitcoin@b91d983f66), which walks the live, not-yet-successful outbound connections rather than a ring at all.
A live node syncs against a real bitcoind (closes #374)¶
tests/integration/bitcoind_test.pystarts a disposable regtestbitcoind, connects a freshNodeto it over p2p, and asserts the node’s own tip against bitcoind’sgetbestblockhashonce it reachesNodeStatus.BlockSynced(closes #374): every other p2p test in this suite connects oneNodeto another, which showsbtclib’s p2p implementation working against itself and nothing about it meeting a Bitcoin Core it did not write.tests/integration/is new, following section 7 of the organization standard – each test skips itself withoutBTCLIB_NODE_INTEGRATIONset, excluded from bothtestpathsand the coverage ratchet, and covered instead byintegration-bitcoind.yml, which fails if what it runs skips rather than reaches a node..github/actions/install-bitcoinddownloads the pinned release the workflow points the test at, its version and digest read offbtclib’s andbitcoin-core-rpc’s own copies of the same action rather than either file copied whole.
Two waits stop sitting tighter than wait_until’s default (closes #476)¶
test_a_slow_manager_start_cannot_still_clobber_the_status_it_racedandtest_downloadeach passedwait_untilatimeoutunder its own default of 60, with nothing beside either saying why (closes #476):tests/helpers.py’s own docstring argues the timeout bounds a failure and not a success, so a generous limit costs a passing run nothing and only delays one that was going to fail – a bound worth keeping tight only where the test itself asserts that the timeout fires, which neither of these does. Both now let the default stand.
A connection stops reading once its queue piles up unprocessed (closes #462)¶
Connection.runpausessock_recvoncequeued_recv_bytes– every octetparse_messageshas handed toP2pManager.messagesandhandle_p2p(p2p/main.py) has not yet popped – crossesMAX_QUEUED_RECV_BYTES, resuming once enough of it drains (closes #462):P2pManager.messageswas a plaindeque, pushed to unconditionally by every connection’s own read loop and drained byNode’s own loop at alog2-scaled share of its length, with nothing stopping a peer sending valid messages faster than that share drains them from growing the queue without bound.MAX_QUEUED_RECV_BYTESmatches Core’s ownrecv_flood_size, 5,000,000 bytes fromnet.h’sDEFAULT_MAXRECEIVEBUFFER * 1000, exactly – not the size of this node’s own worst legitimate receive burst,download.py’s own 16-block_request_new_block_workbatch atMAX_PROTOCOL_MESSAGE_LENGTHeach, 64,000,000 bytes, which was the first number tried and is the wrong shape: Core requests that identical 16-block burst per peer too and still caps at 5,000,000, read at bitcoin/bitcoin@b91d983f66, pausing reading mid-burst on its own ordinary IBD traffic by construction, because the bytes already sent sit in the kernel’s own receive buffer rather than being dropped, and the read resumes once the queue drains. A bound sized to fit the whole legitimate case, the wayMAX_QUEUED_SEND_BYTESwas before #442, never distinguishes flooding from ordinary traffic.Pausing, not dropping the message or the connection – Core’s own answer,
fPauseRecv, which stops selecting a connection’s socket for a read event rather than discarding anything already parsed, all read at bitcoin/bitcoin@b91d983f66: a connection over this bound has sent nothing but valid protocol messages faster than this node currently drains them, not a protocol violation to punish the wayMAX_QUEUED_SEND_BYTESpunishes a connection already over its own send budget.P2pManager.messages’ own items now carry a fourth element, the message’s own wire size, whathandle_p2pweighs back offqueued_recv_bytes;handshake_messagescarries no such element, that queue being drained whole every pass ofNode’s own loop rather than sharing this pacing.
RpcConnection reads 64 KB at a time, copying a body O(1) times (closes #466)¶
_recv_untilreads into a 64 KB buffer, matching Core’s own HTTP server –HTTPServer::SocketHandlerConnected’schar buf[0x10000](src/httpserver.cpp, bitcoin/bitcoin@b91d983f66), itself adapted from the p2p read loop this tree’s own sibling already cites – rather than 1024 bytes with no argument behind that number (closes #466): fewer syscalls per request, andself.bufferis abytearraywhose+=extends in place instead of copying everything held so far the waybytes += bytesdid – the same shape #438 fixed on the p2p side. That fix’s own third part, deferring a parse until the buffer holds a whole message, has no equivalent here:_recv_untilalready returns only once its own predicate – the header terminator found, or the declaredContent-Lengthreached – holds, andrunparses nothing before that, so there was no per-chunk parse attempt to defer in the first place.
The Windows classifier comment now names its known blockers (closes #429)¶
pyproject.toml’s classifier comment said Windows was left out only because nothing here runs it, not because anything is known to stop it (closes #429): that was false. The comment now namesloop.add_readerandsignal.SIGTSTPas already-known blockers, says what changed aboutSIGTSTP’s own reach when its handler moved intoinstall_signal_handlers, and says the list is a lower bound rather than an inventory.test.yml’s own header restated the same false claim in its own words, to explain why it keeps nowindows.ymlsentinel; it now points at the classifier comment instead of repeating it. Whether to support Windows at all stays issue #430’s to decide.
Mempool._evict_to_limit reads its worst entry off a heap (closes #457)¶
Mempoolkeeps_feerate_heap, a min-heap of individual feerate pushed once per accepted transaction, and_evict_to_limitreads the current worst entry off it instead of scanning every held transaction once per eviction round (closes #457): theminscan btclib-org/btclib-node#441 measured and deliberately left alone is gone. A wtxid’s feerate cannot change while it is held – this mempool has no fee-bump or replace-by-fee path – but a wtxid can still leave and come back (a reorg’s own reconciliation is one path that does this), so a heap entry is discarded once its own second element no longer matches_heap_current_seq’s current record for that wtxid, not merely once it names a wtxid the mempool no longer holds: membership alone cannot tell a re-added wtxid’s fresh entry from its own leftover first-spell entry, and the two do not tie-break the same way._poprebuilds the heap from scratch once its stale entries outnumber what is actually held, so a mempool that runs for a long time under its limit, the ordinary case, does not carry one heap entry for every transaction it has ever accepted. Bitcoin Core avoids this cost differently, keepingm_txgraph’s own live package-score structure rather than a heap that tolerates stale entries at all (TrimToSize,src/txmempool.cpp:909, bitcoin/bitcoin@58a7869f86); the substitute here is narrower, matching btclib-org/btclib-node#441’s own reasoning for the index it added instead of that structure.
Node.run’s idle sleep is 5 ms, not a tenth of a millisecond (closes #440)¶
Node.run’s loop sleepsIDLE_SLEEP_SECONDS, 5 ms, once a pass finds nothing waiting in either queue (closes #440): the figure it replaces sat below the platform timer’s own granularity, so an idle node’s actual pace was set by the OS rather than by the sleep, and every one of those passes still randownload_manager.step()andupdate_chain()in full. Raising it cuts an idle node’s own CPU cost by most of what it was paying, at a latency added to work arriving while the loop sleeps that stays a small fraction oftests/helpers.py’s own 25 ms poll inwait_untilandwait_until_listening. Core’s own message loop takes the shape of a wait on a condition variable a producer signals rather than a plain sleep (CConnman::ThreadMessageHandler,src/net.cpp, up to 100 ms, bitcoin/bitcoin@b91d983f66); this loop still spins on a fixed interval instead of being woken, which the citation does not paper over.
RPC error messages now match Core’s own rendering, closing #450 and #451¶
getblockheader’s andgetrawtransaction’s ownRPC_MISC_ERRORusage strings now matchRPCArg::ToString(oneline=true)’s own rendering of their declared arguments (closes #450):getblockheader’s used to omit its trailing optionalverboseentirely, andgetrawtransaction’s wrapped its two optional trailing arguments in two separate( ... )groups whereRPCMethod::ToStringopens one only on the first required-to-optional transition and closes it once, after the loop – both read atbitcoin/bitcoin@b91d983f66,src/rpc/util.cpp:775-798.getrawtransaction’s own argument keeps this tree’sverboserather than Core’s own first name for it,verbosity: this node’s own argument answers only the boolean shape Core’sallow_bool=truedegrades to, not the full0/1/2verbosity Core’s name is for, and the usage string is where that choice is now argued rather than left silent.Every
RPC_TYPE_ERRORthis node raises for a wrongly typed argument now builds Core’s own"Wrong type passed:\n{...}"wrapper (closes #451), keyedPosition N (name)the wayRPCMethod::HandleRequest’s own type check builds it (src/rpc/util.cpp:652-661), rather than answering the bare sentence that value would carry at that one key on its own.rpc/errors.py’s newtype_errorbuilds the wrapper from the argument’s own one-based position and declared name, both of which a raise site already has to hand or reads offbool_param’s ownposition;CLAUDE.md’s Following Bitcoin Core is why this tree’s own prior agreement on the bare shape, across every site including the two issue #443 added, was not by itself a reason to keep answering Core’s own surface differently from Core.
update_chain’s own block reads stay outside the trial’s rollback (closes #452)¶
A raise from
_blocks_to_add/_rev_blocks_to_removestops the node rather than rolling the trial back (closes #452): both read this node’s own already-validated blocks and reverse patches back offblock_db, so a raise there is this node’s storage failing to give back what it wrote, not a fork turning out bad. Core’sConnectTipanswers a failed read the same way, withFatalError; itsDisconnectTipanswers the same failure plainly and leavesFatalErrortoActivateBestChainStep, one level up, which holds any failure to walk its chain backward fatal – a bad read among them – on the same chain-advance pathupdate_chainmirrors (src/validation.cpp, bitcoin/bitcoin@b91d983f66). The two calls keep their place ahead of the trial’s owntry, now argued in a comment beside them rather than left to be read as an oversight, andtest_a_missing_reverse_patch_stops_the_node_rather_than_rolling_back(tests/unit/init_test.py) drives a realNode.runthrough exactly this raise to pin it.
get_cfilters paces itself against Connection’s own send queue (closes #442)¶
get_cfilterssends from agetcfiltersrange only whileconn.queued_send_bytesstays under a new, much smallerMAX_CFILTERS_INFLIGHT_BYTES, handing what it could not schedule tonode.pending_cfiltersforresume_cfilters(p2p/main.py) to finish on a later pass ofNode’s own loop (closes #442): this node has no message-processing stage to pause and resume the way Core’sfPauseSenddoes (net.cpp, bitcoin/bitcoin@b91d983f66) –get_cfiltersruns once, onNode’s own thread, and cannotawaitthe drain the way a coroutine could, so the range it has not yet sent is a plaindequethis node’s own loop keeps coming back to instead.MAX_QUEUED_SEND_BYTES(connection.py) no longer has to hold one whole legitimategetcfiltersanswer, now that one is paced: it is sized for a legitimategetdataburst instead – still unpaced, filed as #470 – plus the new pacing bound.A second
getcfiltersarriving while the first is still paused extends the same connection’s own pending range rather than replacing it, up toMAX_PENDING_CFILTERS_HEIGHTS– two full requests, the pipeliningMAX_QUEUED_SEND_BYTESalready tolerated before this change – past which a third stacked request is silent, the same answer this node already gives a request_filter_rangedeclines for other reasons.
claude-review.yml’s comments name the right subcommands, job and ceiling¶
The
claude_argscomment names thegh prsubcommands the prompt uses (issue btclib-org/.github#398):diff,reviewandview.The
mentionjob’s credential step refuses in the words of the job it guards (issue btclib-org/.github#402): that job answers an@claudemention and reviews nothing, so the step is named Refuse to answer without a credential and its annotation says the workflow answers nothing without the secret.The comment above that step points at the review job’s reason rather than restating it (issue btclib-org/.github#410): the restatement narrated a measurement made on the review job – a token found empty, a review reported successful – inside the job that reviews nothing.
The header’s argument for the job’s slot carries no figure (issue btclib-org/.github#405): the ceiling on concurrent jobs belongs to the organization, so the other repositories’ matrices compete for the same slots, and that is the whole of the argument.
REPOSITORY.md’s The concurrent-job ceiling has the command that reads the plan the limit is documented for, where a figure in a comment goes wrong in silence the day the plan moves.Section 11 of the organization’s standard is cited in a full form and a subsection form rather than the two concatenated (issue btclib-org/.github#400):
section 11's *Review*where Review holds the rule cited;section 11 of the organization's standardwhere no one subsection does, the two secret stores a Dependabot-initiated run reads being stated in the section’s own prose and in Dependabot and pre-commit.ci; and the same full form again wherever the sentence is the one that names the standard, which is what the file’s first citation of it is. What chooses the shape is what holds the rule and never where the sentence sits. A rule that lives elsewhere in the standard is still cited with no section number at all.
getdata paces itself against Connection’s own send queue too (closes #470)¶
getdataserves an item at a time from aGetDataonly whileconn.queued_send_bytesstays under a newMAX_GETDATA_INFLIGHT_BYTES, handing what it could not serve tonode.pending_getdataforresume_getdata(p2p/main.py) to finish on a later pass ofNode’s own loop (closes #470): the same mechanism #442 gaveget_cfilters, reused rather than Core’s own “at most one BLOCK item per call” (ProcessGetData,net_processing.cpp:2798, bitcoin/bitcoin@b91d983f66) – a byte bound produces the same shape without a second, per-item-type count to keep in step withMAX_QUEUED_SEND_BYTES, many small transaction items fitting under it in one pass the way Core’s own “process as many TX items as possible” does, and a block item large enough on its own that one or two exhaust it.MAX_QUEUED_SEND_BYTES(connection.py) no longer has to hold one whole legitimategetdataburst either, now that this answer is paced too: it is sized for each pacing bound plus one item past it –advance_getdataandadvance_cfiltersboth check their own bound before the next item is popped and sent, not after, so either can schedule one item beyond its own bound before the next check catches it – for both mechanisms together, superseding the figure #442 left it at.A second
getdataarriving while the first is still paused extends the same connection’s own pending items rather than replacing them, up to a newMAX_PENDING_GETDATA_ITEMS– two full requests,MAX_INV_SZapiece. Core’s own protection here is not a numeric cap:ProcessMessages(net_processing.cpp:5429-5436, bitcoin/bitcoin@b91d983f66) declines to read a connection’s next message at all while itsPeer.m_getdata_requestsbacklog is still non-empty, which this tree cannot reproduce without restructuringP2pManager.messagesfrom the single queue shared by every connection it is today into one queue per connection.notfoundcovers only what one call actually served, not the whole original request: an item never reached because the pacing bound tripped first is reported once a later call gets to it, matching Core’s ownvNotFound, built fresh by everyProcessGetDatacall rather than carried across them.The batch size
_request_new_block_work(download.py) asks a peer for is now a named, publicMAX_BLOCKS_PER_GETDATA_BURST, where it used to be an untied literal cross-referenced only in a comment onconnection.py’s own formerMAX_QUEUED_SEND_BYTESderivation: that derivation no longer needs it, but the fact it named – this node never asks a peer for more than sixteen blocks at once, matching Core’s ownMAX_BLOCKS_IN_TRANSIT_PER_PEER– outlives the bound it used to be sized from, and is what a peer answering this node’s own request sends back in one burst, still relevant to this connection’s own buffering on the receiving end.MAX_QUEUED_SEND_BYTES(~12.3 MB) sits above both of Core’s own flat, content-blind per-connection figures: its send buffer default (1,000,000 bytes, cited above) by more than an order of magnitude, and its receive buffer default (recv_flood_size, 5,000,000 bytes) by roughly two and a half times. Neither is the number this bound matches: both are sized without reference to any one message’s content, where this bound is sized from two real message sizes, a block and a filter, for the reason the paragraph beside it argues – this node’s own dispatch has no incremental pause-and-resume loop of Core’s own shape to lean on for the rest of an answer.
The signal handlers move out of Node.__init__ (closes #436)¶
Node.__init__no longer callssignal.signal(closes #436): a secondNodebuilt in one process used to replace every handler with one bound to itself, leaving the first running with its databases open once an operator’s interrupt reached the newer node instead, andsignal.signalraises outside the main thread of the main interpreter, so aNodecould not be built there at all. The three handlers move toinstall_signal_handlers(node), a new function next toNodein__all__, called explicitly by the one caller in a process that wants an operator’s interrupt to reach a given node –scripts/chains/calls it right after building the node it starts. Matches Core’sAppInitBasicSetup, whichAppInitcalls andmainin turn calls, registering its signal handlers at process start-up rather than in a constructor (src/init.cpp,src/bitcoind.cpp, bitcoin/bitcoin@b91d983f66).
Connection reads 64 KB at a time and copies a message O(1) times (closes #438)¶
Connection.runreads into a 64 KB buffer, matching Core’s ownpchBuf(src/net.cpp, bitcoin/bitcoin@b91d983f66), rather than 1024 bytes with no argument behind that number (closes #438): fewer syscalls per message, andself.bufferis abytearraywhose+=extends in place instead of copying everything held so far the waybytes += bytesdid.parse_messagespeeks the 24-byte envelope’s ownlengthfield inbufferbefore building a stream or callingMessage.parseat all, so a chunk that does not yet complete the first message inbufferreturns without copying anything – the common case on a connection carrying one large message, a block during initial block download chief among them, over many reads. Alengthalready pastMAX_PROTOCOL_MESSAGE_LENGTHfalls through the gate instead of being waited on, soMessage.parse’s own refusal of it still fires as soon as the header arrives rather than once (if ever) that many octets did.
BlockDB serializes its own reads and writes (closes #432)¶
BlockDBnow holds oneRLockacross every public method (closes #432):open_block_fileandopen_rev_fileare each one handle with one file position shared by everyseek,readandwritereaching it, and nothing serializedadd_block/finalizeagainstget_block/get_rev_blockbefore this – a write landing between a reader’s ownseekand itsreadmoved the position out from under it, answering with whatever bytes were there instead of the block or patch asked for. The lock matchesKeyValueStore’s own “one connection, and a lock around every use of it” (db.py:58-73), one lock for the whole instance rather than one per handle:files, the size bookkeeping__add_data_to_fileupdates, is shared by both the.blkand the.revside regardless of which handle a call is writing through.
Mempool._descendants walks a spend index, not the mempool (closes #441)¶
Mempoolkeepsspent_by, adict[bytes, set[bytes]]from a spent txid to the wtxids, held in this mempool, that spend it (closes #441), maintained inadd_txand_popalongside the dicts those two already kept in step._descendantswalks it from the eviction root instead of scanning every held transaction once per element of the package it is discovering, so one eviction round’s own cost no longer multiplies the package a peer chose by the size of the mempool it is evicted from: measured against a growing mempool at a fixed package size,_descendantsnow holds flat where it grew with the mempool before._evict_to_limit’s ownminscan, the other O(n) factor the issue named, is untouched and stays linear per round; #457 is where that is measured and argued on its own.
The tx-relay queueing step checks two sets, not two lists (closes #444)¶
_queue_announcements_for_received_txs(download.py) tests membership against asetat both of its peer-controlled loops, not alist(closes #444): whether a wtxid a peer announced is one this node already holds, and whether a wtxid about to be queued to a connection is already in that connection’s owntx_announce_queue.receivedkeeps the list order_send_due_announcementssends in, andtx_announce_queuestays thelist[bytes]connection.pydeclares it as; only the membership test against each now reads asetbuilt alongside it, in the shapehas_italready used one function over. The second loop is the one whose cost a benchmark can show:queuepersists across calls until a connection’s own trickle schedule drains it,receiveddoes not, so a call with little to announce can still face a large accumulated queue – measured directly against that loop as it stood before this change, its own cost was quadratic in the two peer-controlled sizes it multiplied and is linear in their sum after it. The first loop’s ownreceivedandinv_txsare each reset every call, so its argument is the one the issue itself makes: a list scan repeated once perinv_txsentry costs more than a set built once, without a benchmark behind that half.
RpcConnection.run bounds the whole request read (closes #437)¶
RpcConnection.runnow reads a request underREQUEST_TIMEOUT(closes #437):_recv_untilused to awaitsock_recvwith no deadline anywhere in it, so a client that connected, sent a byte and stopped kept its socket – and its entry inRpcManager.connections, since onlysend()popped it – open for the life of the node. The bound is spent once, on the whole read from accept to a complete request, rather than reset on eachsock_recv: a per-read timeout alone would still leave a client trickling one byte at a time unbounded. It matches Core’s own-rpcservertimeoutdefault, 30 seconds (DEFAULT_HTTP_SERVER_TIMEOUT,src/httpserver.h), though not Core’s own mechanism – Core resets that timer on every receive and every send (httpserver.cpp:930,1275) and its ownDisconnectClients(:1098-1100) only disconnects a client idle between requests on a connection carrying more than one, and this tree’s own connection is one request per socket, with no such “between” for a reset to find.RpcManager’s own periodic sweep the issue also named was not taken: the peer-to-peer side’s_prune_stale_connectionsfits a peer connection that is legitimately idle between messages, and an RPC connection is one request with no such gap to distinguish from a stall.run’s own catch-all now also popsmanager.connectionson every failure, not only the JSON-parse branch, which already did: an unterminated header, an overstated or negativeContent-Length, and a peer going away mid-request each raiseConnectionErrorthere, and none of them reachedsend()– the only other place popping that table – so each left its id behind regardless ofREQUEST_TIMEOUT.
claude-review.yml converges to the organization’s current mechanism¶
The
reviewjob now gates on the organization variableCLAUDE_REVIEW_ENABLED(issue btclib-org/.github#364), on the job rather than a step: unset organization-wide, so apull_requestrun skips cleanly instead of failing at “Review against REVIEWING.md” the way every run has since the action’s own SDK call started erroring, a cause that issue leaves unestablished.The guard step that reports a review which never ran now reads
api_error_status,stop_reasonand.resultoff the SDK’s execution file (issue btclib-org/.github#385) when the review step did not succeed, instead of reporting only that it failed.The verdict now posts as a pull request review of type
COMMENT(gh pr review --comment, never--approveor--request-changes) rather than an issue comment (issue btclib-org/.github#340), and the verification step now readspulls/<n>/reviewsinstead of issue comments; the verdict lines areACK,CHANGES REQUESTEDand now alsoNACK.The
reviewjob’s timeout is 20 minutes, with a 15-minute ceiling on the review step itself: a review that exhausts its own budget now fails that step, with the runner’s own line saying so, rather than the job being cancelled by the outer limit with nothing in the checks to show for it.
NodeStatus.Reindexing goes (closes #445)¶
NodeStatusno longer declares aReindexingmember (closes #445): nothing assigned it, and its position betweenHeaderSyncedandBlockSyncedmeant every inequality comparison against either end would apply to a reindexing node the moment something did, without anyone having chosen which side of each it belonged on. The issue reserved a decision between naming that ordering explicitly and removing the member until there is a reindex to represent; this takes the removal, matchingget_mempool_info’s own refusal to answer fields it has no source for (#305). A reindex is represented again by whatever change implements one, together with the code that sets it.
_prune_stale_connections continues past a removed connection (closes #435)¶
The
Closedbranch of_prune_stale_connections’s first loop nowcontinues (closes #435): without it, a connection removed there was still the loop variable for the idle check right below, and withlast_receivefrozen at whatever it stopped at andping_sentstill0, that check ransend_pingon a connection already out of bothconnectionsandpending_connections– drawing a nonce and taking_ping_lockfor a socketremove_connectionhad just handed toconn.stop().#357is what made_ping_lockprotect exactly that state.
send_version’s nonce ring keeps the newest ten, not the oldest (closes #433)¶
Connection.send_versiontruncatesmanager.nonceswith[-10:]instead of[:10](closes #433): the old slice kept the first ten nonces this process ever drew, so past the tenth connection every freshly appended nonce was discarded on the same line, andcallbacks.version’s self-connection check compared an incomingversion’s nonce against ten connections long gone rather than any connection still in flight.[-10:]keeps the ten most recently sent, the ringmanager.nonceswas written to be.
Node.run sets status before starting either manager (closes #398)¶
Node.runnow assignsself.status = NodeStatus.SyncingHeadersbeforep2p_manager.start()andrpc_manager.start()rather than after both (closes #398):listeningis set on a manager’s own thread, so a caller whosewait_until_listeningreturns learns nothing about whetherNode’s own thread has reached that assignment yet. A test writingnode.status = NodeStatus.HeaderSyncedright afterwait_until_listeningcould race it, and a late write fromNode’s own thread landing after the test’s putstatusback belowHeaderSyncedfor the life of the node –_ready_forknever returns past that again, so the chain never extends and a functional test waiting on it times out at 60 seconds, on a machine loaded enough to descheduleNode’s thread in that window. Every reader outsideNode’s own loop comparesstatusagainstHeaderSyncedorBlockSynced; the one that namesSyncingHeadersitself,headersinp2p/callbacks.py, is reached only from inside that loop, which both statements precede on that same thread, so it cannot observe the order between them either way. Moving the write earlier therefore changes no behaviour other than closing the window.
A missing argument to testmempoolaccept/sendrawtransaction (closes #443)¶
An empty
paramsno longer reachesparams[0]unguarded. Both callbacks used to raiseIndexErroron it, whichhandle_rpcanswersINTERNAL_ERROR/ “Internal Error” – the code this node owes its own fault, for a call that was merely short of a required argument. Each now raisesRpcError(MISC_ERROR, ...)carrying the method’s own oneline usage, the same shapeget_block_hash,get_block_headerandget_raw_transactionalready answer this with, derived fromRPCArg::ToString(oneline=true)oversendrawtransaction’s andtestmempoolaccept’s own declared arguments (src/rpc/mempool.cpp:72-77and:291-298, read atbitcoin/bitcoin@b91d983f66).testmempoolaccept’srawtxsis now type-checked before the loop that reads it. A JSON string is itself iterable in Python, so a non-listrawtxsused to be walked one character at a time rather than refused; Core declares this argumentRPCArg::Type::ARR, type-checked before the handler body runs, the same mechanismblockhashandtxidare already checked against elsewhere in this file.
A stop at or below the locator no longer raises (closes #434)¶
A known
stophash at or belowgetheaders’s own resolved locator no longer raisesValueError(closes #434): the previous check looked forstopin the whole ofheader_index, where the answer is built from the slice after the locator, and astopbelow it is in the first without being in the second.p2p.main.handle_p2pread the exception as this node’s own bug and dropped the peer for it, on a request Core answers without incident – with nothing to send where the locator is already this node’s own tip, and with the headers past the locator otherwise,stopbeing unreachable going forward from it.
header_index_pos resolves a locator in O(1), not a list scan (closes #439)¶
BlockIndexnow keepsheader_index_pos, adict[bytes, int]from hash to position, besideheader_index(closes #439), the same waychainworksits besideheader_dict(#201) andchildrenbesideheader_dict’s own lineage (#125).get_headers_from_locatorsnow resolves a locator by lookup rather than by scanning the whole known chain of headers, once per entry the peer’s own locator carries – so neither the size of the chain nor the length of a locator the peer chooses is anything agetheadersrequest can turn into more work for this node.The answer is sliced to 2000 before
stopis looked for, not after: the tail past the resolved locator is no longer copied in full only to be capped oncestophas already been searched for across the whole of it.
The docs gate warns against --only-group docs (closes #425)¶
CONTRIBUTING.md’s The environment and the gates now names--only-group docsas the wrong substitute for the docs-gate command’s own--no-default-groups --group docs(closes #425):--only-groupexcludes the project along with every other group, so autodoc’s own import ofbtclib_noderaisesModuleNotFoundErrorunder-Won a.venvthat does not already have it installed, and says nothing on one that does from an earlieruv syncoruv run pytestin the same session – an outcome that tracks the.venv’s history rather than the tree the command is meant to check.docs.ymlalready carries the same warning beside its own copy of the command; this is that reasoning reaching the file a contributor reads before running the gate by hand.
docs.yml’s job is a required check on main¶
Branch protection’s
required_status_checksnow names three contexts instead of two:Lint and type-check,test: every job passed, andBuild the documentation– the follow-upREPOSITORY.md’s Required checks on main names beside the entry below, applied once that job had a green run onmain.REPOSITORY.mdis updated to match, and its documentedgh apiPATCH now reads-F strict=truerather than-f strict=true: the latter sends the JSON string"true", which the API refuses for a boolean field.
docs.yml earns its place in the release path (closes #264)¶
release.ymlgains adocs:job, callingdocs.ymlthe waylint:already callslint.yml, and named inpublish-testpypi’s andpublish-pypi’s ownneeds:(closes #264): a tag now publishes only once its own tree’s documentation has built, not merely once whichever commit last ran the check onmainhas.REPOSITORY.md’s Required checks on main names the three contexts branch protection is to require; thegh apiPATCH that adds the third to the live setting is that section’s own follow-up, applied outside this pull request rather than carried by it.docs/source/conf.pysetshtml_theme = "furo", replacingsphinx_rtd_theme, andpyproject.toml’sdocsdependency group follows (issue #402): section 3 of the organization standard.The docs build runs
-nalongside-W, indocs.yml,.readthedocs.yaml,RELEASING.mdandCONTRIBUTING.md(issue #402): section 5 of the organization standard.conf.pygainssphinx.ext.intersphinx, mapped against python and btclib, and anitpick_ignorelist, each entry reasoned, for the references neither inventory answers – ruff’s own “TC” family moving a typing-only import underTYPE_CHECKINGon this tree’s>=3.14target is what blocks autodoc from resolving the annotation it renders in most of them, a python doc-versus-implementation-module mismatch accounts forasyncio.AbstractEventLoop, and two local type aliases this tree documents nowhere account for the rest.
The py.typed entry’s stale test path is corrected here (closes #421)¶
The
py.typed/__all__entry (btclib-org/.github#239, further down this file) still citestests/unit/main.pyforfrom btclib_node import Node, main: that file istests/unit/main_test.py(closes #421). The citation was accurate when written and went stale under #26/#268’s later rename; the entry it sits in already landed, so it stands uncorrected and this entry is the correction instead of a rewrite of it.
tests/unit/rpc/manager_test.py cites the right test module (closes #419)¶
The module docstring now cites
tests/unit/rpc/main_test.py(closes #419): it namedtests/unit/rpc/main.py, which does not exist – every test module in this tree ends in_test.py, per #26/#268.
rpc.connection’s Connection becomes RpcConnection (closes #417)¶
src/btclib_node/rpc/connection.py’sConnectionis renamedRpcConnection, along with every annotation, import and docstring mention acrossrpc/callbacks.py,rpc/main.py,rpc/manager.pyand their tests (closes #417): it shared its bare name withp2p/connection.py’s own unrelatedConnection, and the docs build’s-Wfails on Sphinx’s “more than one target found for cross-reference ‘Connection’” wherever autodoc renders one as a type hint. Both classes reach every annotation that names them only through aTYPE_CHECKING-only import, so autodoc can never introspect the real class behind either annotation and falls back to the bare word written in the source –autodoc_typehints_format, which only reformats a type hint autodoc did resolve, has nothing to qualify in that fallback and leaves the warning unchanged. Renaming one of the two removes the ambiguity from the word itself, with nodocs/source/conf.pychange and no quoted or dotted annotation needed at any call site.
tests/unit/init_test.py’s comment names the right test modules (closes #415)¶
The comment above
test_every_message_waiting_is_taken_before_the_loop_waitsnow citestests/unit/p2p/main_test.pyandtests/unit/rpc/main_test.py(closes #415): it namedtests/unit/p2p/main.pyandtests/unit/rpc/main.py, which do not exist – every test module in this tree ends in_test.py, per #26/#268.
test.yml’s coverage job gates both 3.14 and 3.14t¶
The coverage job is a two-cell matrix over the interpreter,
3.14and3.14t, andtest: every job passedrequires both (closes #387): the two cells run as parallel jobs, so the second one costs one more job at the organization’s concurrency ceiling and no extra wait, which is what buys it a place in the gate rather than in a weekly sentinel beside it – the tradeos-macos.yml’s own header states for a platform row, read the other way.3.14treaches the 100% floor[tool.coverage.report]already declares with no change to that configuration;.python-versionandrequires-pythonboth stay3.14.
worker_pool builds a ThreadPool under free threading, closing issue #388¶
Node.worker_poolbuilds aThreadPoolrather than a processPoolwheresys._is_gil_enabled()answersFalse(closes #388): a new_pool_factory(gil_enabled=...)– a pure function with the predicate injected, so both arms construct and are asserted on either interpreter – is what the property calls with that reading, rather than branching inline. Under a GIL build the choice still favours a process pool,btclib-secp256k1’s cffi call not releasing the GIL across it, soPoolstays the default there.The
ThreadPoolarm answers the two questions issue #388 raised against it, by reading and by running rather than by trusting a process-era comment:btclib’s script engine (2a93afb3cdfaad5df25d1ec2516f9899e28c5ce2) never writes to theTx,TxInorPrecomputedTxDataa task is handed, andbtclib-secp256k1verifies through a single libsecp256k1 context its own “Thread safety” section documents as safe for concurrent calls. Under this arm,interpreter._tasks’ ownprecomputedis what becomes Core’s rawPrecomputedTransactionData*, shared by reference across a transaction’s own per-input tasks exactly asCScriptCheck::txdatais shared across Core’sCCheckQueuethreads (validation.h, bitcoin/bitcoin@794a753958), where the process arm still ships each task its own pickled copy._WORKER_PROCESSES/_default_worker_processesare renamed_WORKER_COUNT/_default_worker_count: the size they compute names a thread count on the new arm as much as a process count on the existing one, and the reasoning againstpytest-xdist(issue #46) that sizes it holds for both, an OS thread competing for a core exactly as an OS process does once the interpreter running it is free-threaded..python-version’s own comment no longer points at issue #306, closedNOT_PLANNED, as the open question behind the3.14pin: it points at this issue and at issue #385 instead, and says why the pin itself does not move now thatworker_poolreadssys._is_gil_enabled()at runtime – that is not the same as switching the default interpreter this tree runs under.
scripts/ gets real docstrings, D100’s own deferral is gone (issue #373)¶
Every module under
scripts/– the threechains/launchers, the two hand-edited reset templates,prune.py,test_errors.pyandtestnet_test.py– carries a module docstring grounded in what it does and how it is meant to be run (issue #373): a one-off template edited by hand before each use, a manual diagnostic run directly against local fixtures, or a launcher invoked as a plain script, each said which.pyproject.toml’s own"scripts/**" = ["D100"]per-file-ignore, left in place by issue #264 and split out untouched by #373’s owntests/**restructuring, is removed now that the directory it deferred is clean.tests/**’s own seven per-subdirectory keys for the same eight codes are unaffected and remain #373’s outstanding scope.
tests/unit/rpc/ and tests/functional/rpc/ get real docstrings (issue #373)¶
D100/D104/D101/D102/D103/D107/D205/D401are selected fortests/unit/rpc/andtests/functional/rpc/(issue #373): every module, class, function and__init__across both directories now carries a docstring grounded in what it actually tests, read against the source and the test body rather than restated from the function name –RpcManager’s own accept-queue mechanism among them. The twopyproject.tomlper-file-ignore keys these codes occupied are removed outright now that both directories answer zero. This is one of #373’s five paralleltests/**slices; thep2p/bucket undertests/unit/andtests/functional/, thetests/unit/-root enumeration, andtests/{__init__,conftest,helpers}.py, remain.scripts/**’s andtests/unit/chainstate/**’s own slices already landed.
docs/source/index.rst’s toctree names SECURITY.md and RELEASE_NOTES.md¶
docs/source/security_link.mdanddocs/source/release_notes_link.mdjoin the existing four*_link.mdshims, andindex.rst’s toctree names both, in the positionbitcoin-core-rpc’s own index.rst uses (closes #390):SECURITY.mdandRELEASE_NOTES.mdlanded at the repo root with the tier-1 promotion and neither had a shim, so the documentation build resolvedREADME.md’s own links to either file to a plain GitHub blob link rather than to an in-site page.
no-hyphen-at-end-of-line, the organization’s other pygrep hook, joins local-link-prefix¶
.pre-commit-config.yamlcarriesno-hyphen-at-end-of-linebesidelocal-link-prefix, matchingbitcoin-core-rpc’s own pattern andtypes: [markdown]scoping (closes #392): section 4 of the organization standard lists both pygrep hooks as adopted organization-wide, and this tree carried only the first. The lines elsewhere in this file that the hook would have caught – a word and an inline code span identifier each wrapped at their own hyphen – are rewrapped so--all-filespasses clean.
CLAUDE.md names Core’s own checkout and the coverage floor’s load-based flake¶
CLAUDE.md’s Following Bitcoin Core now says a checkout of Core is kept beside the primary checkout of this repository, not beside whichever worktree a session is working in, and names thegit worktree list --porcelainrecipe that finds the primary checkout’s sibling from any worktree (closes #396): a plain../bitcoinresolves only from the primary checkout, and a raw network fetch of one file is no substitute for the local checkout either, since it can come back truncated with nothing to say so, missing the very function a divergence question is about.CLAUDE.md’s Non-obvious facts now names the coverage floor’s other flake, beside theCOVERAGE_FILEone already there: ISS 372 measured a run reporting99.98%and missing the 100% floor on a branch insideP2pManager’s own background thread while every test passed, distinguished from a real regression only by the load averageuptimegave at that run.
src/btclib_node/’s own root modules get real docstrings (issue #373)¶
D101/D102/D103/D105/D107are selected forsrc/btclib_node/’s own root-level modules (issue #373): every class, method, function,__init__and magic method under__init__.py,chains.py,config.py,constants.py,db.py,download.py,exceptions.py,interpreter.py,log.py,main.pyandmempool.pynow carries a docstring grounded in what it does, or a reasoned per-file suppression where one would only repeat its class’s own –exceptions.py’s own trivial__init__s, deferred topyproject.toml’s own per-file-ignore rather than a near-identical one-liner apiece.block_db/,chainstate/,p2p/andrpc/are the remaining four slices, each deferred the same way #264’s ownD100/D104split already deferredtests/**andscripts/**.D205/D401/D403/D404/D105, the small style codes issue #373 asked to fold into whichever slice landed first, are selected tree-wide forsrc/btclib_node/,scripts/and.github/scripts/: stray findings outside this slice’s own root modules –D205andD401inrpc/,D205inp2p/,D401andD403in.github/scripts/check_vendored_pin.py– are fixed directly, since none is tied to a directory’s own D101-D107 sweep still being incomplete.scripts/test_errors.py’s own finding is aD103instead, picked up alongside them since it was one function;D101/D102/D103/D107stay unselected forscripts/**otherwise, deferred withtests/**, which carries findings of its own forD205andD401and is deferred the same way.
src/btclib_node/block_db/ gets real docstrings (issue #373)¶
D101/D102/D103/D107are selected forsrc/btclib_node/block_db/(issue #373, slice 2): every class, method and__init__underblock_db/__init__.py–RevBlock,BlockLocation,FileMetadataandBlockDBthemselves, and their public methods – now carries a docstring grounded in what it does.block_db/’s own private helpers (the double-underscore file lookupsBlockDBkeeps for itself) stay undocumented, a leading underscore already keeping a method outside every one of those four codes.chainstate/,p2p/andrpc/are the remaining three slices, each still deferred the way #264’s ownD100/D104split deferredtests/**andscripts/**.
src/btclib_node/chainstate/ gets real docstrings (issue #373)¶
D101/D102/D103/D107are selected forsrc/btclib_node/chainstate/(issue #373, slice 3): every class, method and__init__across__init__.py,block_index.py,filter_index.pyandutxo_index.pynow carries a docstring grounded in what it does –contextual.pyalready had one for everything it defines.chainstate/__init__.py’s own module docstring is corrected alongside its newChainstatedocstring: it claimed each of the three indexes is kept in its ownKeyValueStore, whereChainstate.__init__opens one store and hands the same object to all three, told apart by key prefix alone.p2p/andrpc/are the remaining two slices, each still deferred the way #264’s ownD100/D104split deferredtests/**andscripts/**.
src/btclib_node/p2p/ gets real docstrings (issue #373)¶
D101/D102/D103/D107are selected forsrc/btclib_node/p2p/(issue #373, slice 4): every class, method, function and__init__across__init__.py,address.py,callbacks.py,connection.py,main.py,manager.pyandmessages/errors.pynow carries a docstring grounded in what it does and, where the code’s own cross-thread behavior is what a docstring would otherwise get wrong, checked against every real call site rather than assumed – caught this way before landing:Connection.sendandConnection.send_pingare each reachable fromNode’s own thread and fromP2pManager’s alike, not from one alone.rpc/is the remaining slice, still deferred the way #264’s ownD100/D104split deferredtests/**andscripts/**.
src/btclib_node/rpc/ gets real docstrings (issue #373)¶
D101/D102/D103/D107are selected forsrc/btclib_node/rpc/(issue #373, slice 5): every class, method, function and__init__acrosscallbacks.py,connection.py,errors.py,main.pyandmanager.pynow carries a docstring grounded in what it does, several cited against Bitcoin Core’s own source at the commit each was read at. This is the fifth and last of #373’s own package-directory slices: no directory undersrc/btclib_node/defers any of the four any longer. Left open, unchanged from #264’s own scope and not part of the five-slice split:D100/D104fortests/**andscripts/**, andD205/D401fortests/**, both still deferred by their own per-file-ignore rather than read individually.D403/D404/D105are clean tree-wide,tests/**andscripts/**included.
tests/**’s own D-family per-file-ignore splits by subdirectory¶
The single
"tests/**"per-file-ignore entry for those eight codes is now one key pertests/unit/subpackage (issue #373): no docstring is written and no finding is fixed by this change –uv run ruff check --select <those eight codes> --statistics tests scriptsreports the exact same total before and after. This is infrastructure ahead of the five parallel pull requests #373’s own remaining scope is about to be split into, one per new key, so each can remove only its own line without conflicting with the other four’s. Verified directly rather than assumed:ruff’s own glob matches across a/even for a bare*, so the bucket fortests/unit/’s own root-level files (no subpackage of their own) is an explicit enumeration by name, not a glob that would also silently catch the four subpackages split out beside it – confirmed by removing each new key in turn and checking that only its own files’ findings reappear, never a neighbor’s.
tests/unit/chainstate/ gets real docstrings (issue #373)¶
tests/unit/chainstate/’s own per-file-ignore key is removed entirely (issue #373): every module, class and function under__init__.py,block_index_test.py,contextual_test.py,filter_index_test.py,init_test.pyandutxo_index_test.pynow carries a docstring, eachtest_*function’s own naming the scenario its assertions actually cover rather than restating the function name. One test’s own name and comment,block_index_test.py’stest_reject_header_above_the_pow_limit, described a mechanism the code does not take: mainnet’s own proof-of-work limit is harder than regtest’s, so a header claiming it never reachesassert_valid_pow’s range check at all, and is refused instead because an unmined nonce does not satisfy so hard a target – renamed totest_a_header_claiming_a_target_it_was_never_mined_to_is_refusedand its comment corrected to match. This is one of #373’s five paralleltests/**slices; thep2p/andrpc/buckets undertests/unit/andtests/functional/, thetests/unit/-root enumeration, andtests/{__init__,conftest,helpers}.py, remain.scripts/**’s own slice already landed.
tests/unit/’s own root-level files get real docstrings (issue #373)¶
D100/D104/D101/D102/D103/D107/D205/D401are selected for every.pyfile directly intests/unit/– not itsp2p/,rpc/orchainstate/subpackages, each its own slice – plustests/__init__.py,tests/conftest.pyandtests/helpers.py(issue #373): every module, class, fixture and test function across these files now carries a docstring, including everytest_*function, none left to a per-file suppression. A test’s own docstring says what scenario it verifies and why, read from what its body actually asserts rather than restated from its own name –coverage_floor_test.py’s test for a run with nolfcache plugin is the sharpest case, its own docstring turning onrelax_coverage_floor’s short-circuitingorchain only reachingoption.lfonce every other narrowing option is already falsy.block_db_test.py’s docstrings are cross-checked againstBlockDBitself, already documented from this issue’s own second slice. This is the root-level bucket of the five parallel slicestests/**’s own per-file-ignore was split into; its own two keys are now removed outright rather than narrowed, the family having no finding left under either.
tests/unit/p2p/ and tests/functional/p2p/ get real docstrings (issue #373)¶
Both per-file-ignore keys for the eight-code family are removed entirely (issue #373): every module, class, function and
__init__undertests/unit/p2p/andtests/functional/p2p/now carries a docstring, eachtest_*function’s own naming the scenario its assertions actually cover rather than restating the function name, checked againstsrc/btclib_node/p2p/callbacks.pyandsrc/btclib_node/p2p/connection.pyrather than against the test’s own name.callbacks_test.py’stest_a_pruned_peer_is_let_go_only_once_the_blocks_are_syncednames its own scenario with a variable calledprunedcarryingNODE_WITNESS, where the servicecallbacks.versionactually gates the drop on isNODE_NETWORK; the docstring describes the real gate rather than the variable’s own name. This is the last of #373’s five paralleltests/**slices to close, pastscripts/**,tests/unit/chainstate/,tests/unit/rpc//tests/functional/rpc/andtests/unit/’s own root-level bucket, all already landed. The rpc slice’s own landing renamedtests/functional/rpc/**’s per-file-ignore key totests/unit/chainstate/**instead of deleting it outright, silently reintroducing a key the chainstate slice had already removed; that key is removed again here, alongside this slice’s own two, since the family now has no finding left under any of the three.
The docs gate’s own remaining gaps close: [project.urls], local-link-prefix¶
conf.py’sBLOBconstant readspyproject.toml’s own[project.urls].repository(closes #378): that table landed with the tier-1 promotion (issue #286), afterdocs/source/(issue #264) had already worked around its absence with a localREPO_URLconstant – removed now that the table it stood in for exists.local-link-prefixis a pre-commit hook here too (closes #379): section 4 of the organization standard carries it in every repository, and it refuses a local markdown link destination that does not begin./– the shapedocs.yml’s own built-page grep cannot always catch, since an unresolvable link written without the prefix renders indistinguishably from a real autodoc anchor.check-readthedocsvalidates.readthedocs.yamlthe waycheck-dependabotalready validates.github/dependabot.yml: the hook existed and this repository’s own.pre-commit-config.yamlsaid outright why it was left out, a sentence issue #264 made false the day.readthedocs.yamllanded.
stop()’s own leftover loop.stop is cancelled, closing issues #377 and #380¶
stop()’s own first line,self.loop.call_soon_threadsafe(self.loop.stop), now keeps theHandleit returns and cancels it oncejoin()above has returned (closes #377, closes #380): that call only schedulesloop.stop, delivered only once something drives the loop’srun_foreverfar enough to reach it, and nothing does where the manager’s own thread was never started or whererun()raised before ever reachingrun_forever– a bind failure being the ordinary way. Everyrun_until_completestop()goes on to call, this method’s own thread now driving the loop instead, used to be primed to collide with that leftover callback and raiseRuntimeError('Event loop stopped before Future completed.'): the grace step guard issues #368 and #362 each added answered “wasstart()called”, true from the moment of that call and well beforerun_forever, not “didrun_foreverever deliver this method’s own scheduled stop” (issue #380); and the unconditional drain loop beneath the grace step carried no guard of its own at all, for a task whose cancellation-unwind needs a second real step to finish (issue #377).Handle.cancel()on a handle already delivered by the manager’s own thread is a no-op, so cancelling it unconditionally here is correct for the ordinary case and removes the leftover callback outright for the other two, on both methods alike.The grace step itself is now guarded on
self._server_sockets(P2pManager) andself._server_socket is not None(RpcManager), set only oncerunhas bound successfully and is about to scheduleserver’s own accept task: not for safety, which the cancelled handle above already covers unconditionally, but because a manager whoseserverwas never scheduled has no accept task the step could be owed to.
P2pManager.server stops discarding an accepted socket, closing issue #386¶
serverstores what it accepts in a queue, from a plain reader callback registered withloop.add_reader, rather than awaitingloop.sock_acceptinside a task of its own (closes #386): that task’s own future could already carry a connection whenstop’s own blanket sweep overasyncio.all_taskscancelled it directly, andTask.cancelon a task whose own awaited future is already done discards it – forcingCancelledErrorin on the next step regardless of what the future already held, with nothing left holding the accepted socket. Measured against a live listener under load, not only the deterministic race the existing regression tests construct: an instrumented copy ofstop()traced the exact cancel discarding an already-resolvedsock_acceptfuture on a manager fielding real connections. The accepted socket now sits in the queue’s own deque the instant the callback runs, immune to that discard regardless of when a cancel reaches the task waiting on the queue, andserver’s ownfinallycloses whatever a cancellation leaves there.P2pManager.stop’s own grace step is removed rather than given a new guard: it existed only to let a task sitting on an already-resolved future –server’s own formeraccepttask – return normally intocreate_connectionbefore a direct cancel discarded it, whichserver’s new queue makes unnecessary, and to avoid asking a loop for one more step past its own scheduledloop.stopbefore that stop was ever delivered, whichstop_handle.cancel()(issue #377, issue #380) already answers unconditionally. Neither reason applies any longer.
RpcManager.server stops discarding an accepted socket, closing issue #391¶
RpcManager.servernow stores what it accepts in a queue, from a plain reader callback registered withloop.add_reader, rather than awaitingloop.sock_acceptinside a task of its own (closes #391): the identical race #386 fixed onP2pManager.server, inRpcManager.server’s own copy of the same construct –stop’s own blanket sweep overasyncio.all_taskscould cancel that task directly while its awaited future already carried a connection, andTask.cancelon a task whose own awaited future is already done discards it regardless. The accepted socket now sits in the queue’s own deque the instant the callback runs, andserver’s ownfinallycloses whatever a cancellation leaves there.RpcManager.stop’s own grace step is removed rather than given a new guard, for the same reason #386 removedP2pManager.stop’s: the task it protected no longer exists, andstop_handle.cancel()(issue #377, issue #380) already answers the other reason a step like it ever ran.
P2pManager.stop()’s grace step is guarded on self.ident, closing issue #368¶
stop()’s grace step –run_until_complete(asyncio.sleep(0)), givingaccepta chance to land normally before the sweep cancels it (issue #353) – runs only whereself.ident is not None(closes #368): guarding it onpendingbeing non-empty, as before, read a caller’s own tasks created directly onmanager.loopthe same asserver’s ownaccept, and asked a loop that had never delivered its own scheduledloop.stopto run one more step, raisingRuntimeError('Event loop stopped before Future completed.')precisely wherestart()was never called at all.RpcManager.stop()carries the identical guard, for the identical reason (issue #362).
deps-latest.yml and mutation.yml join this repository’s weekly sentinels¶
deps-latest.ymlupgrades every dependencyuv lock --upgraderesolves –btclib’s pinnedmainincluded – and runs the suite and the lint gate against the result (issue #287): weekly, at this repository’s minute, and gating nothing, the way every workflow this calendar adds does – a release nobody has locked yet is not a regression a pull request introduced.mutation.ymlruns acosmic-raysession againstsrc/btclib_node/interpreter.py, the consensus entry pointCLAUDE.md’s architecture section names as what validates (issue #287): a newmutationdependency group carriescosmic-ray, and.github/mutation/interpreter.tomlis the one scope so far. It gates nothing either: a surviving mutant is a missing test, not a regression a merge caused.Issue #287’s own remaining rows are triaged rather than landed by this change:
vendored-vectors.ymlalready existed,os-windows,py-arm-authority,alignmentandos-ubuntuare declined with the measurement on the issue itself,pypi-installwaits on issue #286’s release workflow, andintegration-bitcoind’s own design is issue #374.
docs/source/ exists, hand-written, with a gate that builds it¶
docs/source/documents this package (issue #264): one Sphinx page per package directory,tests/unit/docs_test.pyfailing where a shipped module gains no automodule stanza or a stanza names a module the tree has lost..readthedocs.yamland.github/workflows/docs.ymlbuild it with-W --keep-going, joiningtestandlintas a gate.Every module and package under
src/btclib_node/carries a docstring (issue #264):D100/D104are selected inpyproject.tomlfor the shipped package, at zero findings.D101/D102/D103/D107– the class, method, function and__init__narration this pass did not attempt – are issue #373’s.
This repository is tier 1: a release path exists, published on nothing yet¶
.github/workflows/release.yml, tag-triggered, callstestandlintbefore publishing to PyPI (closes #286): the maintainer’s decision to promote this repository to tier 1 supersedes the tier-2 state PR #171 landed one day before this issue was filed, and section 2 of btclib-org/.github’s README measures the tier frompyproject.tomland this file alone. It does not calldocs:.github/workflows/docs.yml(issue #264) is reporting-only, not a required check, and this workflow calls only what already gates a merge — the same reason it does not callos-macos.ymleither, both argued inrelease.yml’s own header comment, which names what would makedocsearn a job here.test.yml’s newdistjob builds the sdist and the wheel, normalizes the sdist’s member metadata (.github/scripts/normalize_sdist.py), writes a CycloneDX bill of materials over the two files (.github/scripts/generate_sbom.py), and checks them withtwine check --strict,check-wheel-contents([tool.check-wheel-contents]’s ownpackagediffing the wheel againstsrc/btclib_nodein both directions) andpyroma --min 10— on every pull request, not only at a release, so a defect a release would hit is one a review already has.check-sdistdiffing the sdist against git is unchanged, already running as a pre-commit hook since PR #265.The packaging smoke test installs the wheel with
--no-depsand checks only its metadata (issue #381): PyPI’s own btclib has no release carryingbtclib.p2p.negotiation, whichsrc/btclib_node/download.pyimports unconditionally, so an ordinarypip install btclib-nodecannot resolve today whatever floorpyproject.tomldeclares. Filed rather than fixed here, no floor this bundle could declare making PyPI satisfy it.RELEASING.mdandRELEASE_NOTES.mdreturn (closes #286): the two files PR #171 removed under the tier-2 decision, back under the shape section 2 gives a tier-1 repository.CONTRIBUTING.md’s A version, and no release becomes A release path, and nothing published on it yet to match:pyproject.tomlstill declares0.1.0, and thepypi/testpypienvironmentsRELEASING.md’s One-time setup describes do not exist yet, so nothing here changes what a checkout runs or how a caller reaches this code — no version is published, no tag is cut.SECURITY.mdcarries this node’s own policy, not the organization’s shared one (closes #286): the file section 2 of btclib-org/.github’s README owes a tier-1 repository.README.md’s Limitations, not vulnerabilities moves there, its own reason for holding them — publishing nothing for a policy of its own to travel with — no longer holding;README.mdends with the “actively supported by” line section 2 gives every publishing repository’s own instead.
Connection’s ping state is one step against the two threads that touch it¶
send_pingandcallbacks.pongshareConnection._ping_lock(closes #357): each writes or clearsping_sentandping_nonceas a pair, and the lock makes each pair one step against the other’s, so asend_pinglanding betweenpong’s own two clears no longer has its fresh nonce overwritten by the sentinel0–send_ping’s own comment is careful never to send it – which used to discourage and drop a peer for a protocol violation this node caused._prune_stale_connectionsreadsping_sentonce, not twice (closes #357): its ownelifre-read the attribute, so apongclearing it to 0 between the two reads dropped a peer that had just answered its ping.Connection.stop’s idempotence comment argues from both of this node’s threads, not from one (closes #360):stopis called fromNode’s own thread as well as fromP2pManager’s, and what makes two threads passing its guard together harmless is that a secondself.task.cancel()and a secondsocket.close()each take an early return with no further effect, andstatusonly ever moves towardClosed– not, as the comment argued before, several calls queued within one turn of a single loop. No code changes.
This node depends on btclib’s libsecp256k1 bindings, closing issue #361¶
btclib’s dependency line carries thesecp256k1extra (closes #361): signature verification runs through btclib’s libsecp256k1 bindings instead of the pure-Python elliptic-curve path, a single-thread speedup.CLAUDE.md’s Following Bitcoin Core is why this is a dependency of this package rather than an extra of it: Core does not treat its own libsecp256k1 as optional, and this is consensus-critical arithmetic rather than a convenience. The bindings do not make threads run in parallel under the GIL, so nothing about how this node is concurrent changes. This package’s dependency tree now carries compiled, per-platform extensions, where it carried none.
P2pManager’s connection dicts stay consistent across threads¶
promote_connectionandremove_connectionshare_connections_lock(closes #358): each moves a connection betweenconnectionsandpending_connectionsin two statements, and the lock makes each of those two statements one step against the other’s, so a connectionremove_connectiondecides to stop is never left in neither dict with nothing having stopped it._maybe_dial_more_peersreadsconnectionsandpending_connectionsunder the same lock (closes #355): a connectionpromote_connectionis moving between the two dicts is read as being in one of them rather than, for the width of the move, in neither, which is what let this method draw and redial a peer it already holds._maybe_dial_more_peersreads its ownlivecount under the lock too, separately from the snapshot above (closes #367): two unlockedlen()calls apart, aconnectionsbefore the write and apending_connectionsafter the pop, could each miss the same moving connection and undercount a node that already has enough peers, which is what let this method dial past the target it was told to stop at.get_peer_inforeadsp2p_manager.connections.copy()(closes #356): the live dict is popped from onP2pManager’s own loop, every pass ofmanage_connections, and a pop landing mid-iteration onNode’s own loop raisedRuntimeError: dictionary changed size during iterationout of a client’sgetpeerinfo.P2pManager.sendreadsconnections.get(connection_id)(closes #359): anincheck followed by a subscript is two dict operations, not one, and a connection popped between them raisedKeyErrorout of the send.
RpcManager.stop() gives accept a step first, closing issue #362¶
stop()runs the loop one step before gathering the tasks it is about to cancel (closes #362): the same gap issue #353 closed inP2pManager.stop(), inRpcManager.stop()instead.server()’s ownacceptis a task of its own, reached bystop()’s sweep directly and not only throughserver’s task cascading a cancel onto it.Task.cancelon a task whose own awaited future is already resolved still forcesCancelledErrorinto it on the next step, discarding a socket the kernel had already handed over with nothing left to close it —server’s own except arm already guards this for a cancel arriving through its shield (#323), and could not guard a cancel that reachesacceptdirectly, which is whatstop()’s own sweep did on every call.run_until_complete(asyncio.sleep(0))before the sweep, repeated until a step changes nothing and run only where this manager’s thread was ever started, letsacceptreturn normally intocreate_connectioninstead. The step is repeated rather than run once: the future it waits on is resolved from another thread with no guarantee its own delivery lands within a single step,asyncio.shieldadding a callback hop of its own betweenacceptcompleting andserverresuming with its result.
The importable package sits under src/, closing issue #343¶
btclib_nodemoves undersrc/btclib_node/(closes #343): a package at the repository root is onsys.pathwhenever anything runs from that root, so an import could resolve to the checkout instead of to the installed distribution, which is what section 2 of the organization’s standard moves it to avoid.[tool.uv.build-backend] module-root = ""is deleted rather than changed: that key existed only to override the backend’s own default, which issrc/. Every path this repository’s own configuration and prose named the package by moves with it:[tool.ruff.lint.per-file-ignores]’s two keys,[tool.mypy] files, and the citations inCLAUDE.md,CONTRIBUTING.mdandREVIEWING.md.[tool.coverage.run] sourcekeeps namingbtclib_nodeunchanged – an importable name rather than a path, whichcoverageresolves through the installed package wherever it sits.
stop() gives accept a step before cancelling it, closing issue #353¶
stop()runs the loop one step before gathering the tasks it is about to cancel (closes #353):server()’s ownacceptis a task of its own, reached bystop()’s blanket sweep directly and not only throughserver’s task cascading a cancel onto it.Task.cancelon a task whose own awaited future is already resolved still forcesCancelledErrorinto it on the next step, discarding a socket the kernel had already handed over with nothing left to close it —server’s own except arm already guards this for a cancel arriving through its shield (#312), and could not guard a cancel that reachesacceptdirectly, which is whatstop()’s own sweep did on every pass. Onerun_until_complete(asyncio.sleep(0))before the sweep, run only where there is a task to give it to, letsacceptreturn normally intocreate_connectioninstead — a task the same loop already knows how to close, on its next pass.
CHANGELOG.md’s lint derogation is gone, and codespell now fixes¶
The two-comment directive disabling MD022 and MD032 at this file’s head is deleted (closes #328):
markdownlint-cli2already fixes in place, so amerge=unionjoin dropping the blank line between two###sections is repaired on the hook’s next run instead of failing a gate with nothing to fix it, and the two rules apply to this file again.The local
changelog-heading-blank-linecheck the derogation needed alongside it is gone too, redundant now that MD022 and MD032 cover the same defect and repair it rather than only reporting it.codespellgains--write-changes, joiningmarkdownlint-cli2andtyposas hooks that fix in place instead of only reporting;yamllinthas no fix mode and stays check-only.
The vendored blockfilters.json pin is re-checked weekly, closing issue #327¶
vendored-vectors.ymlre-checks the vendored pin weekly (closes #327):tests/_data/README.mdpinnedtests/unit/chainstate/_data/blockfilters.jsonto a Bitcoin Core commit and blob by hand, with a documented procedure and nothing running it without being asked..github/scripts/check_vendored_pin.pyruns that procedure –git hash-objectagainst the recordedblob, acommits?path=query against the recordedcommit– and the workflow fails where either has moved. It carries no issue-tracking machinery of its own and noissues: write: unlike btclib’scheck_vendored_vectors.py, which serves a README with dozens of vendored headings, this tree’s one entry is answered by the same contents-read, fail-on-drift shapelinks.ymlandbootstrap-dns.ymlalready use for a scheduled report on something outside this tree’s own commits.
select gains FBT, closing issue #341¶
selectgainsFBT(flake8-boolean-trap) (closes #341): the third and last of issue #341’s own real-judgment rounds, and the only one of the three that changes call sites outside the file each fix is declared in – a bareTrue/Falsepositional argument (f(a, True)) is unreadable without the signature open beside it, so the fix is a keyword-only parameter and every caller updated to name it, not a rename. 82 findings, 34 inbtclib_node/, all fixed by making the parameter keyword-only (*,) after confirming, by grepping every call site in the tree first, that none of them was positional to begin with:RevBlock/BlockInfo’s owncheck_validity,BlockIndex.set_downloaded’sdownloaded,Config.__init__’s four booleans (and, since every other parameter there was already keyword at each of its own call sites too, made keyword-only throughout rather than only the four this round is about – which also drops thePLR0913neighbour noqa’s ownPLR0917, positional-count no longer being a thing to measure),Logger.__init__’sdebug,Mempool.get_missing/get_tx’swtxid,Connection.__init__’sinboundand.stop’scancel_task.P2pManager.create_connection’s owninboundparameter needed the same fix one layer up, at its own three production call sites and two intests/.BlockInfo(...)’s own two dataclass-constructor call sites (not a declarationFBT001/FBT002reach, sinceruffdoes not flag an auto-generated dataclass__init__) and twobtclibcalls this tree does not own the signature of (SendCmpct(False, 1), one site inp2p/callbacks.py, andTx.serialize(True), 23 sites acrossrpc/callbacks.pyand the test suite): allFBT003, fixed by naming the argument at the call site since the callee’s own signature is not this tree’s to change.A pytest test function’s own parametrized boolean parameters (
p2p/callbacks_test.py’stest_what_a_peer_said_about_relay_lands_on_the_connection): made keyword-only like every other finding here, verified by running the test directly rather than assumed safe –pytest.mark.parametrizecalls a test function by keyword, matching fixture and parameter names, so this is not a special case needing its own reasoning.socket.socket.setblocking, 16 sites acrosstests/unit/p2p/connection_test.pyandtests/unit/rpc/connection_test.py: declined, the oneFBT003finding this round could not fix either way – verified directly (socket().setblocking(flag=False)) that the standard library’s own C-level method takes no keyword arguments at all, so there is no call-site rewrite available and no signature of this tree’s own to change. A newpyproject.tomlper-file-ignore for each of the two files, since everyFBT003remaining in each is this one call.Every
FBTfix’s call sites were found by grep across the whole tree first, not by an editor’s local references, the same discipline issue #284’s own builtin-shadowing round used:scripts/turned up two moreLogger(...)positional callsmypycaught (Too many positional arguments for "Logger") that atests/- andbtclib_node/-only grep had missed, which is the reasonmypystays the round’s own real check rather than the grep that started it.
select gains ANN, the first of issue #341’s own three real-judgment rounds¶
selectgainsANN(flake8-annotations) (issue #341): the first of a further three rounds past issue #340’s own mechanical sweep, each needing a per-site read rather than a rule’s own safe fix.ANN001/ANN201and the rest of the family that names a missing annotation report zero – issue #166 already annotated every signature inbtclib_node,testsandscripts– soANN401(Anydisallowed as a parameter or return annotation) is the whole of what this round found and the whole of what it is about.ANN401,btclib_node/(6 of 77): read individually rather than bulk-suppressed, since a placeholderAnyand a genuine escape hatch look the same to the rule. Two were dead code, not merely under-annotated:log.Logger.__init__’s**kwargsforwarded tologging.Logger.__init__, whose own signature (name,level) both arrive already named, and no caller in this tree ever supplied a third;rpc.connection.JSONEncoder.__init__’s*argsforwarded tojson.JSONEncoder.__init__, which is keyword-only and never receives a positional argument fromjson.dumps’s own construction of it. Both parameters removed. Two narrowed toobject, since nothing at either site does more than store or type-dispatch on the value:rpc.errors.json_type_name’svalue(looked up in adict[type, str]bytype(value)alone) andrpc.main.is_valid_rpc’srequest(narrowed byisinstancebefore anything else touches it) –rpc.errors.error_msg’srequest_idthe same way, echoed into the response unread. The one genuine escape hatch left,JSONEncoder’s own**kwargs, keepsAnywith anoqaand a comment: it forwards tojson.JSONEncoder.__init__’s own heterogeneous keyword arguments (bool,int | None,tuple[str, str] | None, a callable), not one type to narrow to.ANN401,tests/(71 of 77): read individually rather than swallowed as a family. 67 of the 71 are one idiom, run throughout the whole suite – aSimpleNamespacestanding in for a production type across the fields one scenario needs and none it does not, or a spy forwarding blindly to the real callable it wraps (socket.socket,asyncio.Task.cancel,Loop.run_until_complete) – and are declined together, in a newpyproject.toml"tests/**"per-file-ignore entry that names the idiom and the 4 exceptions rather than the rule wholesale, following the existing entries forPLR0913/PLR2004/S101/S311. The 4 exceptions are narrowed rather than declined:filter_index_test.py’sCountingDb.write_batchreturns exactly whatKeyValueStore.write_batchreturns (AbstractContextManager[KeyValueStore]), not an unrelatedAny;init_test.py’sARecordingPool.starmapstores its arguments without calling them, soCallable[..., object]andIterable[Iterable[object]]say what the real ones would without claiming more;rpc/callbacks_test.py’sa_chain_index_nodealready built its return value withcast("Node", ...)before returning it, so the function’s own declared return type could simply beNoderather than repeatingAnyone line later.
select gains ARG, issue #341’s second real-judgment round¶
selectgainsARG(flake8-unused-arguments) (issue #341): 138 findings, read individually rather than assumed to be the same shape as each other – 33 inbtclib_node/, 105 intests//scripts/.btclib_node/__init__.py’sstop_handler:signal/framerenamed_signum/_frame,signal.signal’s own calling convention being the reason both are unread, and the renamed pair no longer shadows thesignalmodule this file also imports.p2p/callbacks.py(13 findings) andrpc/callbacks.py(15 findings): every one read against its own dispatch table (callbacks/handshake_callbacksin each file, called uniformly byp2p.main’shandle_p2p/handle_p2p_handshakeand byrpc.main’shandle_rpc) – an argument one handler does not read is required by the table’s own shared signature, not a mistake, and renaming it to_would lose that signature’s own documentation for a table that calls every handler the same way. Declined together, in a newpyproject.tomlper-file-ignore for each of the two files, rather than 28 near-identicalnoqacomments.p2p/manager.py’smanage_connections: its ownloopparameter was read nowhere in the body –asyncio.run_coroutine_threadsafetakes the loop as its own second argument, not through the coroutine it schedules – and was removed outright, at its one call site and in the one test that drove it directly.p2p/manager.py’sbroadcast_raw_transaction:feestays, declined with anoqanext to the comment already there explaining why – the same caller already recorded it inMempool.add_tx, which is where the BIP133 feefilter check reads it from.p2p/messages/errors.py’sReject.parse:check_validitystays for the same reasonserializebeside it does (exempt from this rule as an@override) – every btclibPayload’s own parse/serialize pair takes it, called polymorphically without a caller knowing which subclass is on the other end, even thoughReject’s own wire format has nothing it would gate.tests//scripts/(105 findings): read individually rather than swallowed as a family. 103 are monkeypatch replacements or fakes matching the real callable’s own signature they stand in for – a dispatch handler swapped into acallbacksdict the same way production’s own are,Connection.send,update_chain,Loop.getaddrinfo/sock_accept,random.expovariate, and the like. The other 2, both intests/functional/p2p/block_filters_test.py: a pytest fixture (mark) requested by two tests only for its own construction’s side effect, not for a value either test reads. All 105 declined together in the existing"tests/**"per-file-ignore (ARG001/ARG002/ARG005added alongside the entries already there, the comment naming both reasons rather than only the larger one).
select gains TC, the first family beyond issue #284’s own reference selection¶
selectgainsTC(flake8-type-checking) (issue #340): the largest and most mechanical family of a further sweep past issue #284’s own reference selection, every rule ruff ships rather than only the standard’s own list. A typing-only import moved underif TYPE_CHECKING:costs nothing at runtime on this tree’s>=3.14target – PEP 649’s lazy annotation evaluation is native there, andFA(already selected) confirms it: only one file in the whole tree still needsfrom __future__ import annotations, not every module a typing-only import would otherwise reach.TC006(atyping.cast()call’s own type argument, quoted): 46 sites,btclib_node/andtests/alike,ruff’s own safe fix applied after reading every site for the one thing that fix is not safe for – a type expression spanning more than one line, or carrying a comment on any line but its last. None did.TC001/TC002/TC003(an application, third-party or standard library import moved underTYPE_CHECKING): 109 sites, 57 of them inbtclib_node/.ruff’s own static check is what decides an import is typing-only, and the risk worth checking by hand rather than trusting it is a name that check missed at runtime – anisinstance, a decorator, a default value. Read individually before the fix:socket(p2p/connection.py,rpc/connection.py),pathlib.Path(chainstate/__init__.py,log.py,p2p/address.py) andconcurrent.futures.Future/collections.abc.Callable(p2p/connection.py,rpc/connection.py,rpc/manager.py) are each the kind of name that is often both a type and a runtime constructor or protocol elsewhere, and each is confirmed, by reading every one of its own uses in the file that flagged it, to appear only in an annotation there.uv run ruff check --unsafe-fixesapplied to the rest without a per-site read of every one of the 109 – the real check is not a grep’s own guess at completeness but the tree actually running:mypy(which would refuse a name it cannot resolve inside an annotation) and the full suite (which would raiseNameErrorthe moment a runtime use reached a name that no longer exists outsideTYPE_CHECKING) both ran clean afterward, unchanged in count from before this round.
select gains G, N and PERF, and records what stays out¶
selectgainsG(flake8-logging-format),N(pep8-naming) andPERF(Perflint) (closes #340): the second, smaller round of a further sweep past issue #284’s own reference selection, alongsideTC(issue #340’s own first round).G004(alogger.*call’s own f-string argument): 15 sites, all inbtclib_node/. 7 converted byruff’s own safe fix; the other 8 – every one where the interpolated value was an attribute access, a method call, or an!s/!rconversion rather than a bare name – converted by hand, read individually so the%s/%rargument still says what the f-string said.{e!s}becomes a plain%sargument (str, exactly what!salready called);{self!r}becomes%r(repr, exactly what!ralready called) – checked directly, not assumed, since%and!conversions do not otherwise line up one-for-one. Five tests, acrosstests/unit/p2p/connection_test.pyandtests/unit/p2p/callbacks_test.py, stood a barelist.appendin for theLoggermethod they read a message back from – one argument only, where the production call each of them exercises now passes two.tests/helpers.pygainslog_recorder, applying the same%formattingloggingitself would before recording the message, so each assertion reads the same finished string either way.N806(a function-scoped name not lowercase), one renamed, one declined:interpreter.py’scheck_transactionshadFLAGS, read as a possible function-scoped constant the way #284’s own round 3 declinedSIM300’s constant-on-the-left – but this one differs per call, keyed on the blockindexpassed in, so it is an ordinary write-once local rather than a constant; renamed toflags.tests/unit/rpc/manager_test.py’sAcceptResultis a type alias, not a variable, and PEP 8’s own naming convention for one is CapWords, the same as a class – declined,noqa: N806.PERF401/PERF403(a manual loop or dict-update where a comprehension already says the same thing):download.py’s_pending_and_waiting_blocksandtests/unit/p2p/messages/init_test.py’spayload_classes, bothruff’s own unsafe fix, reformatted to this tree’s own 88-column wrap afterward.pyproject.toml’sselectcomment gains a paragraph on what this sweep declined, and why, so it is not resurveyed:COM(flake8-commas, 361 findings, allCOM812) andQ(flake8-quotes, zero findings) both redundant againstruff-format, which already enforces trailing commas and quote style on everything it reflows – the same reasoningline-too-longis already ignored for.EM(flake8-errmsg, 13 findings) andSLF(flake8-self, 140 findings) are, measured, entirely insidetests/– zero of either inbtclib_node/– a whitebox-test idiom rather than a defect.INP(flake8-no-pep420, 16 findings) isscripts/**and a handful oftests/**/p2pdirectories lacking their own__init__.py; whetherscripts/is meant to be an importable package is a packaging decision this tree has not made, not a lint fix to make it for.
select gains PL and C90, closing issue #284¶
selectgainsPL(pylint) andC90(mccabe) (closes #284): the last two families of the standard reference selection this issue measured against this tree,selectnow carrying every one of the 13 families that reference selection named and this tree did not already have (A,B,BLE,C90,ERA,FURB,PL,PT,RET,RUF,SIM,T20,TRY), plusRUF043andunspecified-encoding, both of which predate this sequence.PLR2004(magic-value-comparison), a named constant where the number is a concept and a decline where it is not:block_index.pyanddownload.pyshareMAX_DOWNLOAD_WINDOW, one bound read from both ends of it – how many candidatesget_download_candidateshands back, and how farblock_downloadlets the download frontier run ahead of the active chain before backing off.download.pygains three of its own,_BLOCK_STALL_EVICTION_TIMEOUTand_BLOCK_STALL_DISCONNECT_TIMEOUT(this tree’s own coarser pair, not Core’s adaptiveBLOCK_STALLING_TIMEOUT_DEFAULT/_MAX, checked directly againstnet_processing.cpprather than assumed to match) and_MAX_CONCURRENT_REQUESTS_PER_BLOCK;p2p/manager.pygains_IDLE_TIMEOUT(this tree’s own bound too, shorter than Core’sTIMEOUT_INTERVAL, same check).p2p/callbacks.py’s own2000becomesMAX_HEADERS_RESULTS– not a new constant of this tree’s own, but onebtclib.p2p.limitsalready exports, matching Core’s own name for it (net_processing.h). Three sites decline, each for a reason checked rather than assumed:block_index.py’s block-locator step doubling at10matches Core’s own unnamed literal (LocatorEntries,src/chain.cpp) – naming it here would claim a meaning Core’s own algorithm never gave it;p2p/address.py’s4isipaddress’s own IPv4 version number, already named by being IPv4;rpc/callbacks.py’s2isgetrawtransaction’s own third positional, already named by the help string raised two lines above it.tests/**gains aPLR2004per-file-ignore for the rest: a test’s own literal expected value is not a magic number needing a name.PLC0415(import-outside-top-level), fixed at every site rather than declined anywhere: most were a test needing the module object itself, not a name out of it, tomonkeypatch.setattragainst –import btclib_node.p2p.callbacks as cbandimport btclib_node.rpc.callbacks as cb, repeated once per test that needed it where one shared, hoisted import serves every one of them just as well. A few were genuinely redundant:tests/unit/init_test.pyre-importedbtclib_nodeinside three functions when the module was already bound at the top of the file, andtests/functional/rpc/chain_test.py/tx_test.pyeach importedbitcoin_core_rpc/btclib.fetch.bitcoin_coreinside their one user apiece for no reason a circular import or an optional dependency gives – both packages are ordinary, always-installed dependencies, and every other import in both files already lives at the top.C901/PLR0912/PLR0915(complex-structure, too-many-branches, too-many-statements), read function by function before deciding whether each was refactored or tangled by the problem it solves. Every flagged production function turned out to be a sequence of distinct stages sharing little state across them, not a single decision tree that would fragment badly – so each split cleanly into named helpers, the caller left reading as the stages it always was:main.py’supdate_chaininto_ready_fork,_blocks_to_add,_rev_blocks_to_remove,_finalize_forkand_reconcile_mempool_for_reorg;download.py’stx_downloadinto_queue_announcements_for_received_txsand_request_wanted_txs, and itsblock_downloadinto_refresh_block_window,_evict_stalled_connections,_pending_and_waiting_blocksand_request_new_block_work;__init__.py’sNode.runinto_drain_message_queuesand_step_chain;chainstate/block_index.py’sadd_headersinto_validate_header_batchand_insert_pending_headers, matching the function’s own existing comment about the two stages never interleaving;config.py’sConfig.__init__loses its chain-string resolution to a module-level_resolve_chain;p2p/manager.py’smanage_connectionsinto_prune_stale_connections,_maybe_prune_active_addressesand_maybe_dial_more_peers;rpc/callbacks.py’sget_raw_transactioninto_parse_txid,_parse_optional_block_hashand_find_transaction. None of these changes the behaviour of the function it came out of; the tests that already covered each are what confirm that, unchanged.PLR0913/PLR0917(too-many-arguments, too-many-positional-arguments), declined at both of its two production sites, each with anoqaand a reason rather than a reshaping that would only rename the same problem:Config.__init__takes eleven parameters becauseConfigis eleven independent settings and every call site already reads it by keyword (checked – no call site in this tree passes it positionally); nesting them into sub-objects moves the same count behind an extra name apiece for callers who do not have it today.contextual.py’sassert_valid_in_contexttakes six for the same reason, matching Core’s ownContextualCheckBlockHeader’s parameter set for the same check.tests/**gains aPLR0913per-file-ignore of its own: every remaining site is a test double’s own builder, one keyword-only argument per field of the object it stands in for.PLW2901(redefined-loop-name), fixed:block_index.py’sinit_from_dbreused its own loop variablekeyfor the row’s suffix after splitting off the row’s prefix; the suffix isblock_hashnow, and the loop variable is never reassigned.
select gains TRY and BLE, and this tree gets its own exceptions¶
selectgainsTRY(tryceratops) andBLE(flake8-blind-except) (issue #284): weighed each finding as a possible latent bug rather than a style preference, per the round’s own brief, since a narrowedexceptcan turn “logged and the loop continues” into “propagates and kills the loop” for whichever exception nobody meant to let through.btclib_node/exceptions.pygains ten classes, grouped by what actually went wrong rather than by which module raises it —ChainstateInconsistencyErroralone answers every site downstream ofupdate_chainfinding that its own index promised something the data underneath it does not have, acrossbtclib_node/main.py,block_db,block_index,utxo_indexandfilter_indexalike, andStoreClosedErroranswers both ofdb.py’s own “the store is closed” sites.UtxoIndex.add_block’s own two raises share the same two messages as two ofChainstateInconsistencyError’s sites (“prevout not found”, “prevout already spent in this batch”) but not the invariant:add_blockruns against a freshly-downloaded candidate block, beforecheck_transactionshas validated anything about it, so a failure there is a peer’s bad block, not this tree’s own bug —InvalidBlockInputError(ValueError)is that one, matchingMissingPrevoutError’s own shape (the same failure, checked at a different point in the pipeline: mempool reprocessing after a reorg) rather thanChainstateInconsistencyError’s.TRY002(raise Exception(...)) is what asked for a class;TRY003(a message built at theraisesite rather than carried by the class) is why each one takes amessagein its own__init__instead of a barepass— checked directly against ruff itself rather than assumed: the message still has to be assigned to a variable before theraiseand not written as a literal there, which isTRY003’s own actual test and not quite what its rule name or its docstring’s own prose suggest.Config.__init__’schainvalidation gains two of those classes:UnknownChainError(ValueError)for achainstring this tree does not recognise, andInvalidChainTypeError(TypeError)for achainthat is neither aChainnor astr(TRY004, preferTypeErrorfor a wrong type overValueError) — the existingunknown chaintest needs no change, being aValueErrorstill, and theConfig(chain=None)test is updated to expectTypeError.Four
except Exception:sites narrow to the specific exception the surrounding code actually depends on:rpc/main.py’sget_connectiontoKeyError(a plaindictlookup, nothing else the miss could raise), twotests/unit/db_test.pyrace tests to the newStoreClosedError, checked againstKeyValueStore.close’s own locking that no other exception can reach thatexceptunder the race either test drives, andscripts/test_errors.py’s ownTxOut.parseloop toBTClibException, confirmed directly against the installed btclib rather than assumed.Seven
except Exception:sites stay exactly as broad as they were, each now with anoqa: BLE001and a comment saying why:p2p/connection.py’s own two,rpc/connection.py’s, andrpc/callbacks.py’s own two —get_peer_info’s (alongsideS112: a peer disconnecting mid-lookup can surface as more than one socket error depending on timing and platform, and every one of them means the same “skip this peer, ask the next”) andtestmempoolaccept’s (answering one entry per transaction, Core’s own contract for that RPC, so an unexpected failure on one is that entry’s own reject-reason rather than the whole batch’s answer). The two connections’ own read loops (p2p/connection.py,rpc/connection.py) are not guarding a shared event loop from a crash — both are scheduled throughrun_coroutine_threadsafe, and asyncio isolates one scheduled coroutine’s own unhandled exception from the loop and from every other connection on it regardless of how broad thisexceptis. What each catch buys instead, checked against its own connection’s structure rather than assumed:p2p/connection.py’s gives every failure the same explicitstop()its own outerfinallywould eventually reach anyway, in the one place that also decides whether to discourage the peer;rpc/connection.py’s is the only placeself.clientgets closed for a failure in that method, there being no outerfinallythere to fall back on. Two more stay broad for an unrelated reason of their own: propagating a caller-supplied call’s own exception back unchanged (tests/helpers.py’scall_within) or a whole directory of local, unpredictable fixtures’ own failures back as one line each (scripts/test_errors.py’s driver loop).Three
raises stay declined rather than rewritten, each with anoqaand a reason:block_index.py’s header-batch validation raisesBTClibValueErrorfrom inside the sametryits ownexceptlogs and re-raises every refusal through — this one andassert_valid_in_context’s own — so abstracting it out againstTRY301would split one log line into two shapes for no reader’s benefit;rpc/connection.py’s own bounds check onContent-Lengthis the same shape, oneexcept Exceptiondownstream answering everything this method can raise. A third, intests/unit/p2p/manager_test.py, keeps raising a bareOSErroragainstTRY003rather than a class of this tree’s own:_binditself catchesOSError, and a test double standing in for the realsocketmodule has to raise what that module would.
select gains B and the rest of RUF¶
selectgainsB(flake8-bugbear) andRUF(the rest of ruff’s own rules) (issue #284).RUF043moves out of its ownextend-selectentry now that the family it belongs to is selected outright.Node.__init__’sconfig: Config = Config()default becomesconfig: Config | None = None, with the body constructing aConfigwhen the caller omits one (B008): a mutable default built once atdef-time and shared across every call that does not override it is the shapeB008warns about, and nothing here relies on that sharing.Config.__init__’schain: Chain | str = Main()default becomeschain: Chain | str = DEFAULT_CHAIN, a module-level singleton next to the existingDEFAULT_MIN_RELAY_FEERATE(B008, the same ruleNode.__init__’s fix above answers:Configdefines its own__init__rather than one@dataclassgenerates, so its parameter default is ordinaryB008and notRUF009).Config(chain=None)still raisesValueError, unchanged: the singleton replaces the call in the signature, not the type it stands for.BlockInfo’sstatus: BlockStatus = BlockStatus(1)default becomesstatus: BlockStatus = BlockStatus.valid_header(RUF009, the dataclass-field form of the same warning:BlockInfotakes the__init__@dataclassgenerates),BlockStatusbeing anIntEnumwhose members are already singletons.The two loop variables
RevBlock.deserializenever reads become_(B007), matching thefor _ in range(...)shape already used elsewhere in this tree.A
zip()over two sequences a caller can prove are the same length now says so withstrict=True, inupdate_chainand in a functional compact-filters test, whereverB905finds one and the invariant that makes the lengths equal is read out of the code around it rather than assumed.Two
zip(chain, chain[1:])pairs intests/unit/helpers_test.pybecomeitertools.pairwise(chain), the idiomRUF007names and the same changeB905would otherwise have asked astrict=of.Three
wait_until(lambda: ...)calls that close over aforloop’s own variable keep the pattern, againstB023, each with anoqaand a reason:wait_untilitself now carries a comment arguing why the closure is safe — it is read and discarded within the iteration that built it, sincewait_untilreturns or raises before the loop can reach its next iteration and rebind the name — and the three call sites point back to it rather than repeating the argument.Unpacked tuple elements a test never reads become
_(RUF059), acrosstests/functional/p2p/block_filters_test.pyandtests/unit/rpc/main_test.py, checked per site that the name really is unread rather than reached some way ruff’s own static view misses.Stale
# noqa: BLE001and# noqa: SLF001comments come off, intests/unit/db_test.py,tests/unit/p2p/manager_test.pyandtests/unit/rpc/manager_test.py: neitherBLEnorSLFis selected, so nothing was ever suppressing anything at those lines, andRUF100is what now says so.
select gains A, flake8-builtins¶
selectgainsA(flake8-builtins) (issue #284): every local variable, loop variable and function argument namedhashoridthat shadowed the Python builtin of the same name is renamed to say what the value actually is —block_hashwherever it is one, andconnection_id,request_idortxiddepending on which “id” a given site actually holds — rather than to a generic disambiguator likehash_orid_.None of these are wire-facing renames.
error_msg’sidparameter feeds the JSON-RPC 2.0 response’s own"id"key, andget_peer_info’s loop variable feedsgetpeerinfo’s own"id"field — both dict keys stay the literal string"id", untouched; only the Python identifier that holds the value on its way there changes. Checked for every renamed parameter that no caller in this tree passes it by keyword (grep -rnforhash=andid=acrossbtclib_node/,tests/andscripts/), so no call site needed updating alongside a signature.RevBlock’s ownhashfield, andConnection.id(bothp2pandrpc), are untouched.AselectsA003(builtin-attribute-shadowing) too, and it reports nothing here, checked directly rather than assumed: it only fires where an attribute is referenced ambiguously with the builtin it shadows (a callable resolved as the attribute instead of the type, in the rule’s own example), which a plain data attribute assigned once in__init__and only ever read is not.
select gains SIM, RET and FURB¶
selectgainsSIM(flake8-simplify),RET(flake8-return) andFURB(refurb) (issue #284).Node.run’smath.log(x, 2)calls, over how many queued RPC or peer-to-peer messages one pass of the main loop takes, becomemath.log2(x), the redundant-base formFURB163names — a rate limit, not anything consensus- or network-facing. Checked the two forms are numerically interchangeable for every non-negative integer input these call sites can reach, exact powers of two included, before taking the rewrite rather than trusting it.update_chaingains an explicitreturn Noneat the fall-through end of the function, where every other exit is already explicit (RET503). Nothing before this point in the function’s owntry/except/finallyis touched — the fall-through in question is the last statement in the function, well after that block ends.A trivial
assign, then returnpair collapses toreturn <expression>whereverRET504finds one, anif/elseblock that only chooses which value to assign becomes a ternary whereverSIM108finds one, atry/except ...: passbecomescontextlib.suppress(...)whereverSIM105finds one, andis_valid_rpc’s own last two lines becomereturn "id" in request(SIM103).rpc/main.py’s ownSIM108ternary rewrite of a dict-key-with-defaultif/elseblock turns out to still read as anifblock toSIM401once it is a ternary, so that one goes one step further, torequest.get("params", []), rather than stopping at the ternary this round’s ownselectaddition first suggested.init_test.py’s two nestedwithstatements over the same test become onewith (...)(SIM117).init_test.py’sSTOP_TIMEOUT < timeoutkeeps the constant on the left, againstSIM300, with anoqaand a reason:SIM300’s own rationale — preventing an accidental=for==— does not apply to Python, and the constant’s own comment states the claim this way round, the constant being the sentence’s subject.
select gains PT and RUF043, and bare raises carry a reason¶
selectgainsPT(flake8-pytest-style), andextend-selectgains the single ruleRUF043(issue #284).RUF043travels withPTrather than waiting for the rest ofRUF, because the fix forPT011is to givepytest.raisesamatch=, and amatch=carrying an unescaped regex metacharacter is exactly whatRUF043reports; the comment aboveselectsays the rest ofRUFis its own round.Config.__init__’sraise ValueErrorfor an unrecognisedchain,check_transactions’sraise ValueErrorfor a prevout list that does not match its transaction’s inputs, and theraise ExceptioninUtxoIndex.add_block,UtxoIndex.apply_rev_blockandBlockIndex.remove_from_active_chainnow carry a message. Each raised bare before, so nothing told the failures within one function apart;PT011asked the tests here for a narrower catch, and a message the tests canmatch=is the honest answer where the exception itself stays what it already was. Theraise Exceptionsites already carried a barenoqa: B017, PT011with no reason recorded, from beforePTwas selected; a message and amatch=answer both rules at once —B017(still unselected) checks the same wayPT011does — so the wholenoqacomes off rather than narrowing to the half that is not yet enforced.The
pytest.raises(RuntimeError | KeyboardInterrupt)blocks intests/unit/db_test.pythat keep the write before the raise inside the block, againstPT012, each carry anoqaexplaining why: the write has to land on the open batch before the exception that unwinds it, which is what the test is about, so it cannot move out of the block.A composite
assert a and bsplits into twoasserts whereverPT018finds one, so a failure names which half failed.The long comment above
selectre-points its two citations of #25, closed in favour of #284 and #285: the general reference-selectmeasurement and its suggested shape are #284’s, and theD(pydocstyle) paragraph is #285’s.
select gains ERA and T20, and a stale local drops out¶
selectgainsERA(eradicate, commented-out code) andT20(flake8-print) (issue #284).scripts/reset_chainstate.pyandscripts/reset_download.pyare each a template edited by hand before a run rather than a program run as-is — their reset is commented out on purpose, one block per operation, kept current against the block index’s own API across refactors rather than left to rot — soper-file-ignoresturnsERA001off for both files instead of anoqaper block.scripts/test_errors.pygets the same treatment forT20: itsprints are a manual diagnostic run’s progress and result, not debug residue.scripts/chains/mainnet.py’s commenteddebug=True,carries a reason and anoqa: unlikesignet.py/testnet.py’s livedebug=True, this is the one script that leaves its log quiet by default, meant to be uncommented by hand when chasing a mainnet-specific problem.btclib_node/rpc/callbacks.py’s citation of Core’s ownJSONRPCErrorcall carries anoqa: it is C++ prose citingsrc/rpc/server.cppforget_block_hash’s missing-argument error shape, andERA001reads the parenthesised call as commented-out Python.BlockIndex.generate_block_candidates’s unused# header = block_info.headeris gone. Nothing downstream of it ever readheader; the assignment goes back to an optimisation that removed what used to need it and left the line commented rather than deleted.
RpcManager.stop() closes what P2pManager.stop() was already fixed to close¶
The listening socket is now closed explicitly in
stop()rather than only throughserver’s ownwith server_socket:, andserver’s own accept is wrapped inasyncio.shield(closes #323). MirrorsP2pManager.stop()’s own fix for the same two races (#312): a coroutineTask.cancel()reaches before its first step raisesCancelledErrorat its own definition point, so thewithis never entered and the listening socket is never closed; and an accept already resolved at the instant of cancellation is lost past an unshieldedawait.stop()also requests every task’s cancellation in its own pass before awaiting any one of them, asP2pManager.stop()does — here this closes log noise rather than a leak, sinceRpcManager’s own connections sweep already runs after that same loop and reaches a connection landed during it, whereP2pManager’s sweep runs before.RpcManagerhas nodialormanage_connections, so #312’s own third mechanism — an outbound connect losing its socket to the same unguardedexcept— has no counterpart here.
Connection.run no longer takes a parameter it never reads¶
Connection.run’s ownconnectparameter is removed (closes #318). Its body never read it, and no caller —P2pManager.create_connectionor any test — ever passed it a non-default value.
P2pManager.stop() leaves no socket behind¶
Every task is now cancelled before the loop is allowed to run again, and the sweep of the connection dicts and that cancellation repeat until
asyncio.all_tasksanswers with nothing (closes #312).run_until_completeruns the loop, so a single pass of each leftserver()’s accept loop working through the drain: it took what the kernel had left in the listen backlog, andcreate_connectionregistered that connection after the sweep had passed and gave it a task the snapshot taken before the drain could not hold. Nothing closed that socket and nothing ended that task, so what a run saw wasConnection.runpending at its ownsock_recvbeside an unclosed socket, reported against whichever test the collector reached them in.dialcloses the socket it opened when it is cancelled mid-connect (issue #312).CancelledErroris neither anOSErrornor aTimeoutError, so the arm that answers a peer which never came up did not see it:stop()cancellingmanage_connectionswhile it was inside a dial left that socket open with aladdrand noraddr, which is the shape the issue was filed on.server()accepts through a shielded task of its own, so a connection the kernel handed over in the same instant the accept loop was cancelled is closed rather than dropped (issue #312).Task.cancelcannot cancel a future that is already done, so it throwsCancelledErrorin on the next step instead of resuming with that result, and the accepted socket was held by nothing but the frame that unwound.The explicit close of the listening sockets is no longer described as a backstop for a mechanism nobody could name:
server’s ownwith server_socket:is skipped wherestoparrives before that task has taken a first step, which is the same fact the connections sweep turns on (issue #312).test_stop_closes_a_connection_accepted_in_its_own_race_windowhangs its hook onis_aliverather than onjoin(issue #312).stop()reachesjoinonly while the thread is still running, so a manager whose loop had already stopped by then skipped the hook and the test asserted on a socket it had never handed over — passing for the wrong reason on an idle machine and failing on a loaded one.
block_download’s out-of-work branch is now covered on purpose¶
tests/unit/download_test.pynow covers theelse: returna connection’s own turn inblock_downloadfalls into once neitherwaitingnorpendinghas anything left to hand it (issue #319). Whether that branch ran at all used to depend on how the suite’s other tests happened to divide a window’s blocks across connections at that moment; the new test builds three connections already holding the window’s one block between them and a fourth, idle one, so the branch is reached by construction rather than by luck.
P2pManager.stop() sweeps connections after join, not before¶
The sweep that closes every known connection moved to after
join()returns, and the listening sockets are now closed explicitly rather than only throughserver’s ownwith server_socket:(issue #312). A connectionserver()’s own accept loop created in the window betweenstop()schedulingloop.stopand that being delivered used to be missed by a sweep taken beforejoin(), reaching only the generic task-cancellation loop afterward — which cannot closeConnection.clientfor a task cancelled before it ever ran. The sweep now runs once nothing but this thread can still be adding toself.connections/self.pending_connections.Connection.runalso closes its own socket in afinally, the same guaranteeserver’s ownwithalready gave its listener.
REPOSITORY.md points at the release fact instead of restating it¶
REPOSITORY.md’s What is not configured, and why restated that nothing here is released instead of pointing atCONTRIBUTING.md’s A version, and no release (btclib-org/.github#291). The bullet now carries the pointer, in the shapebbt’sREPOSITORY.mdalready uses.
A convention of this tree is not a reason to diverge from Core¶
Where Core defines the surface — an RPC’s field names and what they mean, a message’s semantics — being consistent with the rest of this codebase is not a reason to answer differently from Core, the reader on the other side being a client written against Core.
CLAUDE.md’s Following Bitcoin Core names only a constraint of this tree as grounds for a divergence, which leaves a convention of this tree readable as such a constraint. Units are where that bites hardest: a feerate here is satoshis per kvB wherever one is emitted or read, and Core’sgetmempoolinfoanswersmempoolminfeein BTC per kvB, so the internally consistent answer is the one wrong by eight orders of magnitude. The rule stops where the encoding is not this tree’s to pick: BIP133’sfeefiltercarries satoshis per kvB because BIP133 says so.
REPOSITORY.md’s Merge methods section named the wrong landing subject¶
COMMIT_OR_PR_TITLEis the pull request title only where a branch has more than one commit; a single-commit branch lands under its own subject (closes #302).REPOSITORY.md’s Merge methods section named the pull request’s title unconditionally, in wording the organization standard’s own Merge method section does not carry;bitcoin-core-rpcandportanodealready state the conditional, and this tree’sREPOSITORY.mdnow matches them.
This node’s own feefilter is resent as its mempool’s own minimum moves¶
DownloadManager._send_due_feefilters, called fromstep()like every other per-connection schedule this file keeps, tells each connected peer this node’s own current relay floor and resends it as that floor changes (closes #275). Sent once and never again before this, out ofcallbacks.verack; Core’s ownMaybeSendFeefilter(net_processing.cpp:5822, bitcoin/bitcoin@58a7869f86) is not a handshake action either, reached instead from the ordinary per-peer message loop, so the static send is removed fromverackrather than kept alongside the new schedule. The floor itself isMempool.get_min_fee_rate()(#294), floored atConfig. min_relay_feerate, rounded through a geometric bucket set (_fee_filter_buckets, Core’s ownFeeFilterRounder) that a 2-in-3 draw rounds down from even at an exact boundary, so this node’s own rolling minimum is not readable exactly from what it tells a peer. Resent on an exponential schedule averaging ten minutes, pulled forward to within five minutes of one already due where the floor moves by more than a third; during initial block download every peer is sent the top of the bucket set instead, telling it not to bother, the same as Core.
getmempoolinfo answers maxmempool and mempoolminfee¶
Both read
Mempool.bytesize_limitandMempool.get_min_fee_rate()(#294), the source neither field had before it (closes #305).mempoolminfeeis BTC/kvB, matching Core’s ownMempoolInfoToJSON(src/rpc/mempool.cpp:1075-1086, bitcoin/bitcoin@58a7869f86) rather than this tree’s own sat/kvB used everywhere else a feerate is emitted or read: Core defines this surface, so the unit follows Core here even though it does not match the rest of this tree. The exact eight-decimal string Core’s ownValueFromAmountproduces is written to the wire directly, through a newRawJSON(rpc/connection.py), since a Pythonfloatcannot always carry it without exponent notation.maxmempoolneeds no such divergence, Core’s own field being plain bytes already.minrelaytxfeeandincrementalrelayfeeare left out despite being real and cheap to answer, because #305 named only these two fields; every other field RPC answers is left out because, unlike those two, it has no concept behind it in this tree to read a real number from: none ofusage,total_fee,unbroadcastcount,permitbaremultisig,maxdatacarriersize,limitclustercount,limitclustersizeoroptimal.
CLAUDE.md says this tree follows Bitcoin Core, and what a divergence owes¶
Where this tree reimplements something Bitcoin Core also does, it matches Core’s behaviour wherever that is possible and reasonable, and the comment names the commit Core was read at. What differs from Core in consensus or in relay is a difference the network sees. A divergence a constraint of this tree forces is legitimate —
btclib_node/db.py’s docstring is the worked example — but it is argued where it is made: a citation with an unexplained difference beside it does not tell a reader a decision from an oversight.
CLAUDE.md’s Architecture names which state crosses threads¶
The paragraph #304 added to Architecture has no entry of its own above, and this is it. It says a coroutine enters either manager’s asyncio loop only through
run_coroutine_threadsafewhile their plain methods do not, so what decides whether state needs a lock is which thread reaches it and never which callback names it — which is whyMempoolneeds none andPeerDBcarries two.
CLAUDE.md names the worktree wt-<tracker>-<issue>-<repo>-<role>¶
The recipe named the worktree after the issue alone,
wt<issue>(btclib-org/.github#292). A worktree’s administrative directory lives in the.gitof the repositorygit worktree addwas run from, one per repository, so two repositories cannot collide there; what the recipe left uncovered was a same-repository collision, between two worktrees of different work sharing a generic basename, and a path collision across repositories, since the workers of one session share one scratchpad directory and a session carrying one issue into several repositories computed the same target path for each. The recipe now names the worktreewt-<tracker>-<issue>-<repo>-<role>, most general part first:trackerbecause an issue number is unique only within one tracker,issueagainst the same-repository collision,repoagainst the cross-repository path collision, androleagainst a coder and its reviewer holding a worktree at once.
CLAUDE.md’s primary-checkout paragraph names the read that cannot go stale¶
The paragraph said reading the checkout was fine and so was
git fetch, without sayinggit fetchmovesrefs/remotes/origin/mainand leaves the work tree where it was (btclib-org/.github#255), so agrepor aReadagainst the checkout answered for whenever it was last brought forward. It now namesgit show origin/main:<path>as the read that does not go stale, and gives the fast-forward that brings a clean checkout forward without working in it.
A test that builds a Node, a Chainstate or a BlockDB closes it¶
A fixture builds a regtest
Nodethat is neverstart()ed and closes it:Chainstate,BlockDB, both managers’ event loops and the worker pool (closes #111).run’s own teardown closes all four, and never runs for a node driven directly on the thread that built it, which is the shapetests/unit/main_test.py’s tests andtests/unit/chainstate/filter_index_test.py’s use throughout.tests/unit/chainstate/block_index_test.pyandtests/unit/block_db_test.py, which open aChainstateor aBlockDBdirectly rather than through aNode, get the same factory-fixture shape.tests/unit/init_test.py’sa_networked_nodecloses the realPeerDBits own realP2pManageropened, before replacing that manager with a stand-in that carries none of it: nothing else ever reached the original to close it.filterwarningsis["error"], in place of the blanket['ignore:cannot collect test class'](closes #31). What stays named isTestNet’s own collection warning – pytest collects any class whose name matchesTest*, andbtclib_node.chains.TestNetis one – and, on the handful of tests whose own subject is a warning raised on purpose (an unhandled exception on a manager’s thread that cannot bind, a coroutine a deliberately unrun loop never awaits), apytest.mark.filterwarningsnaming that test alone.
Mempool evicts to its limit instead of refusing everything past it¶
Mempool.add_txevicts the worst individual feerate, and its in-mempool descendants, to make room pastbytesize_limitrather than refusing outright once it is reached (closes #294). A full mempool used to be a wall: nothing already held was ever removed, so a transaction paying whatever fee still could not get in, andget_missinganswered every request with nothing at all rather than letadd_txdecide per transaction. Bitcoin Core’s ownCTxMemPool::TrimToSize(src/txmempool.cpp) evicts the worst chunk, a package score over its whole cluster graph; this mempool keeps no dependency graph to score packages by, so evicting the worst individual feerate together with everything depending on it is the substitute that stays consistent without one –main.verify_mempool_acceptanceadmits a child whose parent is only in the mempool, and evicting a parent alone would leave that child’s own prevout resolving nowhere.bytesize_limitmoves from 500 vMB to 300, matching Core’s ownDEFAULT_MAX_MEMPOOL_SIZE_MB. The old value carried no argument on record for the difference, and was inert while nothing ever evicted; eviction is what first makes it an economic threshold rather than a fixed ceiling, which is the reasoning this number needed and did not have. It is not exposed onConfig: eviction does not need a configurable limit to exist, only a real one.A rolling minimum feerate, Core’s own
GetMinFee, tracks what an eviction round just raised and decays it once a block confirms.Mempool.note_block_connected, called once per blockmain.update_chainconnects, restarts the decay clockMempool.get_min_fee_ratereads.DownloadManagerchecks current mempool membership at send time, both for a transaction newly queued intx_downloadand for one already sitting in a connection’stx_announce_queue: eviction can take a transaction back out between the moment it is queued for announcement and the moment that announcement is sent, which a queue of hashes alone cannot tell apart from one still held.
manage_connections stops redialling an endpoint it just dropped for cause¶
P2pManagerkeeps an in-memory, process-lifetime set of endpointsmanage_connectionswill not dial (closes #283), added to by everyconn.stop()site that drops a connection for incompatibility or for a protocol violation – a self-connection, aversionbelowProtocolVersion, noNODE_WITNESS, noNODE_NETWORKonceBlockSynced, averackout of order, apongwhose nonce does not match, a message ahead of the handshake, and aBTClibExceptionraised while handling one. Not aPeerDBtable and not persisted: Core’s own discouragement is the same shape, aCRollingBloomFilterheld only for the process’s life (banman.h, bitcoin/bitcoin@58a7869f86), and a wrongly discouraged endpoint is recovered by a restart rather than by touching the datadir. An endpoint this node dialled and closed on its own account – an idle timeout, a send-buffer bound – is not marked.An exception
p2p/main.py’s bareexceptturns intoconn.stop()marks the endpoint only where it is aBTClibException. Thatexceptalso catches this node’s own bugs on content a peer sent that was otherwise fine –get_cfilters’s “no filter for a block on the active chain” among them – and those are not cause to discourage the peer that merely triggered them.Connection.run’s own envelope-parsing failure marks the endpoint the same way, and on the sameBTClibExceptionguard. A bad checksum, an oversized length, or a message for a network this node is not on never reachesp2p/main.pyat all –Message.parseand the network-magic check right after it raise straight out ofConnectionitself, which is wheremanage_connectionswould otherwise have redialled the address back the next tick.
PeerDB.addresses gets a lock of its own, separate from the active table’s¶
add_addressesandrandom_addressserialize every touch ofPeerDB.addressesthrough a lock of their own, distinct fromactive_addresses’s (closes #298).add_addressesreaches the set from bothNode’s thread, off gossip throughcallbacks.addrandaddrv2, andP2pManager’s, offget_addr_from_dnsresolving a seed;random_address’s own dialable-address comprehension walks the same set fromP2pManager’s thread while gossip can be mutating it onNode’s. Unprotected, that pairing isRuntimeError: Set changed size during iterationin CPython, not only a lost update.A lock shared with
active_addresseswas measured and declined. Nothing here ever needs the two tables updated as one atomic step, andadd_addresses’s own durable write batch runs measurably longer thanadd_active_address’s single row; sharing one lock would let that batch hold up every completed handshake for no invariant a lock of its own does not already give.
Node’s worker pool is sized to the machine, and always closed¶
_WORKER_PROCESSESis 8 outside of a test run and the machine’s core count divided acrosspytest-xdist’s own workers under one (closes #46).-n autoruns one worker per core, and each independently built everyNodeunder test a pool of 8 processes on top of that; on a ten-core machine under ten workers that is up to 80 processes contending with the ten the cores can actually run, which is what await_untiltimeout in the functional suite was measuring. ReadingPYTEST_XDIST_WORKER_COUNT, whichpytest-xdistsets in a worker’s environment before any test module is imported, keeps the total near the core count instead.run’s teardown joins the worker pool afterterminate()and drops the reference, andNode.__del__closes it too, for aNodethat never reaches that teardown at all (closes #195).terminate()alone left the pool’s workers unreaped, but the reference it left behind was not on its own the source of theException ignored ... OSError: [Errno 9] Bad file descriptorreported against #195:tests/unit/main_test.pybuilds severalNodes and callsupdate_chainagainst them directly, never starting the threadrun’s own teardown lives on, so their pools were never terminated at all rather than merely unjoined – and__del__is what aNodebuilt and used that way still has.
add_active_address settles a redialled endpoint onto its one row¶
A handshake with an endpoint already active settles onto that endpoint’s one row in
PeerDB.active_addressesrather than adding another (closes #270), the wayadd_addresses’s ownby_endpointalready settlesself.addresses.add_active_addressruns once per handshake rather than once per batch, so the lookup is an endpoint-keyed index kept alongside the list rather than a per-call scan of it, which would turn many handshakes against the one peer quadratic overall.
getaddr answers from a cached sample, and the active table prunes on its own¶
The sample
getaddranswers with is drawn once and served to every connection until it expires, rather than redrawn per connection (#71). A fresh random sample per connection let two peers connecting close together compare answers and infer what changed between them, which answering agetaddronly once per connection does not stop by itself, a new connection still drawing its own fresh sample.P2pManager.manage_connectionsprunesPeerDB’s active-address table on its own timer (#71), rather than only as a side effect ofrandom_addressorgetaddr. Both stop reaching for the table once this node already has enough connections or has already answered a connection’s onegetaddr, so a well-connected node nobody ever asks never pruned a stale row.PeerDBserializes every write toactive_addressesand its endpoint index through one lock (#71).add_active_addressreaches them fromNode’s own thread, offcallbacks.verack, and the periodic prune above reaches them fromP2pManager’s, offmanage_connections; unlocked, a position read against one thread’s view of the list could be written into the other’s already-reshaped one, corrupting an unrelated row rather than merely losing an update.The periodic prune runs inside the same
try/exceptmanage_connections’s dial already does (#71).get_active_addressesdoes real I/O against the store, and that coroutine’s own future is never awaited, so whatever it ever raised unguarded would end the whole loop’s pinging, eviction and dialling for the rest of this node’s life rather than only that one prune.
A relay octet that is neither 0x00 nor 0x01 keeps costing the peer¶
Version.parseraising on a relay flag outside0x00/0x01is this node’s policy, not a defect left open (closes #149). Core’s ownUnserialize<bool>(src/serialize.h) reads any nonzero octet as true, so a0x02there reads as a peer asking for relay; btclib’s own docstring calls that reading the malleability its refusal is for – one payload, two possible readings serializing back to only one of them. Reaching Core’s leniency inbtclib_nodewould need either replayingVersion.parse’s whole field walk (bothNetworkAddressentries, thevar_bytesuser agent) just to find where the octet sits in the payload, or matching the wording of theBTClibValueErrorit raises – unlike the stream-based leniencyaddr/addrv2use below, both bind this node to btclib’s private shape rather than its public contract, for a byte Core’s own encoder (Serialize<bool>, same file) can only ever write as0x00or0x01. Pinned bytest_a_relay_octet_that_is_neither_0_nor_1_still_costs_the_peer.
sendrawtransaction refuses a transaction it cannot decode¶
A
rawtxTx.parsecannot decode raisesRpcErrorwith the newDESERIALIZATION_ERROR(-22) and Core’s own message, “TX decode failed. Make sure the tx has at least one input.” (#274), rather than being caught by a bareexcept Exceptionand answered{"result": null}– a shape a JSON-RPC client reads as success. Arawtxof a JSON type other than a string is now named withTYPE_ERROR, the same waygetrawtransaction’stxidalready is.
AManager, the Node.run test stand-in, carries a peer_db¶
AManagergains apeer_dbattribute whosecloseis a no-op (#263).Node.run’s shutdown path callsself.p2p_manager.peer_db.close()unconditionally, whichAManagerdid not carry: a test built on thea_networked_nodefixture that reached that path raisedAttributeErrorin the node’s own background thread, surfacing as an unhandled-thread-exception warning rather than a test failure.
bootstrap-dns.yml runs on the calendar’s own schedule¶
The workflow carries a
schedule:namingcron: "20 5 * * 4"(#272), Thursday at that hour and this repository’s own minute being the row btclib-org/.github#201 gave it in section 10 of that repository’s README. It previously ran onworkflow_dispatchalone, waiting on that row to exist.The header’s paragraph on the absent schedule is rewritten to say where the one it now carries comes from, instead of continuing to describe a state this same change ends.
A peer’s feefilter is honoured, and Mempool keeps a fee per transaction¶
Mempoolnow keeps the fee each transaction paid alongside it (Mempool.fees,Mempool.add_tx’s newfeeargument), andDownloadManager.tx_downloadandP2pManager.broadcast_raw_transactionwithhold a transaction from a connection whose ownfeefilter(#94) it does not clear (#260), through the newMempool.meets_fee_rate. The fee itself ismain.verify_mempool_acceptance’s own sum-of-inputs-less-sum-of-outputs, computed there already and returned rather than discarded.broadcast_raw_transactiongrew a requiredfeeargument to carry it in fromrpc.callbacks.send_raw_transaction, the one caller outside the mempool’s own bookkeeping.
The worker pool’s own cold start moves off the thread that promotes a connection¶
Node.warm_worker_poolbuildsworker_pooland dispatches warm-up calls across its processes, on a thread of its own, right beforedownload_manager.block_downloadsends the firstGetDatafor a block this node does not have (#262).check_transactions’ own first call used to be what built and warmed the pool, onNode.run’s own thread – the same one that drainsp2p_manager.handshake_messagesand promotes a connection once itsverackarrives. Each of the pool’s processes pays its own import ofbtclib_node.interpreter, and through itbtclib.script.engine, on its own first dispatch; while that import ran onNode.run’s thread, a peer whoseverackthe kernel had already delivered sat unpromoted inpending_connectionsuntil the call returned. The import still happens on the same schedule relative to block download, now on a thread a peer’s own promotion does not wait on. A node whose headers are synced but which never has a block to ask for – a header-only peer under test, a peer whose counterpart stops serving blocks – never reaches that line and never pays for a pool it never validates a script with, matchingNode.__init__’s own comment beside_worker_pool.
sendrawtransaction answers a refusal, not a rejected transaction’s txid¶
A transaction
verify_mempool_acceptancefinds invalid is answered-26(Core’sRPC_VERIFY_REJECTED) with the same reject reasontestmempoolacceptalready gives it, and one whose prevout is nowhere is answered-25(RPC_VERIFY_ERROR, Core’s own code for missing inputs) rather than-32603 Internal error(#83). The first case used to be swallowed and answered with the txid, as if the network had taken a transaction this node itself refused; the second fell through to the callback dispatcher’s generic handler, which answers as though this node were the one at fault.send_raw_transactionandtestmempoolacceptnow share the same two reject-reason strings, so the two RPCs agree about the same transaction.
RpcManager.stop waits on its own thread instead of spinning a core¶
RpcManager.stopblocks onself.join()rather than pollingself.loop.is_running()in a tight loop (#257), the same fix #249 gaveP2pManager.stop. The calling thread no longer spins a full CPU core for the scheduling delay of the manager’s own event-loop thread; the join is skipped where the manager was never started, whichNode.runreaches unconditionally for a node built withrpc_portunset.
P2pManager.stop waits on its own thread instead of spinning a core¶
P2pManager.stopblocks onself.join()rather than pollingself.loop.is_running()in a tight loop (#249). The calling thread no longer spins a full CPU core for the scheduling delay of the manager’s own event-loop thread; the join is skipped where the manager was never started, whichNode.runreaches unconditionally for a node built withp2p_portunset.
PeerDB settles on one row per endpoint, on disk and in memory alike¶
add_addressesno longer keeps a second member ofself.addressesfor an endpoint already known, where a later gossip carries differentservices(#247). Two records differing only inservicesused to become two entries: the durableknown-row already settled on the endpoint (keyed on network id, address and port, notservices), but the in-memory table did not, so the 10000-entry cap could be spent on several rows for the one endpoint, andrandom_address’s uniform draw favoured whichever endpoint had been gossiped with more than oneservicesvalue. Updating an endpoint already held does not count against the cap; only a genuinely new one does.get_active_addressesdeletes ananswered-row from the store once the entry it backs ages out ofactive_addresses(#253). Nothing here calledKeyValueStore.deletebefore, so a durable row outlived the endpoint it recorded for as long as the process ran, bounded only by the count of distinct endpoints ever dialled successfully rather than by what is still active.
check-sdist builds with the backend [build-system] declares¶
The
check-sdisthook now carriesargs: [--inject-junk, --installer=pip]andadditional_dependencies: ["uv_build>=0.12.5,<0.13"], naming[build-system]’s ownrequiresrange (btclib-org/.github#197). Without--installer=pipthe hook packs the archive with the outeruv’s own bundled backend regardless of whatadditional_dependenciesnames,check_sdist/sdist.py’sget_uv()finding auvonPATHbefore ever consulting the dependency it was given: with the two specifiers set to disagreeing ranges the hook stayed green.--installer=piprunsbuild --no-isolationinstead, which reads[build-system]from the environment and refuses one that does not satisfy it; measured against a deliberately mismatched range, the hook then fails withERROR Missing dependencies.
.gitattributes carries the organization’s own paragraph on both lines¶
.gitattributesis the standard’s file, byte for byte (btclib-org/.github#192). This tree holds noRELEASE_NOTES.mdand no attribute of its own, so the shared half is the whole file; its hash matches the raw filebtclib-org/.githubserves at its ownmain, andgit check-attr merge CHANGELOG.md RELEASE_NOTES.mdstill answersunionfor both.
The installed package ships py.typed and declares its surface¶
btclib_node/py.typedis in the tree, anduv buildputs it in both the wheel and the sdist (btclib-org/.github#239), needing no change to[tool.uv.build-backend]’s patterns.classifiersgainsTyping :: Typedbeside it, the comment that used to argue the classifier’s absence now arguing why it is there instead.btclib_node/__init__.pydeclares__all__ = ["Node"]: no consumer of the package root, in this repository or out, has ever imported anything else from it —from btclib_node import Node, mainintests/unit/main.pyreachesmainas the submodule Python already registers on import, not as a name__all__would need to carry.
BlockDB’s file counter and filenames no longer have a fixed width¶
The block-file rotation counter is a
var_int, not a fixed-width field (#78).(self.file_index).to_bytes(2, "big")raisedOverflowErroronce the counter grew past what two octets encode, with nothing checking for it beforehand.BlockLocationandFileMetadataread and write their filename length-prefixed, not as a fixed-width slice (#78).file.name[-10:]andstream.read(10)assumed the name was always exactly ten characters, keying or truncating it wrongly once it grew past that.BlockLocation.indexandFileMetadata.sizeparse pastvar_int.parse’s default cap (collateral of the same fix): that cap bounds an item count a peer could inflate, and a byte offset inside a still-open 128MB block file already exceeds it before the file rotates. Bitcoin Core’s own on-disk position,FlatFilePos::nPos, skips the same guard for the same reason.A block or reverse-patch file is matched by its resolved path, not by a basename or a string suffix (#79). Comparing suffixes accepts another directory’s file of the same name; nothing exercises that today since one
BlockDBowns onedata_dir, but the comparison now says what is meant instead of what happens to be true.A block store’s
blocks/directory written before this change cannot be reopened: its records were the fixed ten-octet filename this change removes, andBlockLocation.deserializenow reads the first of those octets as a length prefix instead, raising rather than returning a wrong answer. A node upgrading past this change starts from an emptyblocks/directory.
A reorg checks the transactions it hands back to the mempool¶
update_chainputs a transaction of an abandoned block back into the mempool only onceverify_mempool_acceptancehas passed it (#85), the same check every other entrant into the mempool already went through. A transaction that spent an output only the abandoned branch ever had is now dropped rather than sitting in the mempool answeringgetrawmempoolandgetdatafor something the node’s own acceptance check would refuse. The abandoned blocks are walked oldest first for this, the opposite of the order the utxo undo above it uses, so that a transaction depending on an earlier abandoned block’s own transaction finds it already back in the mempool: Core’sMaybeUpdateMempoolForReorgre-adds the same way (src/validation.cpp).The loop re-adding those transactions to the mempool and the one removing the newly connected chain’s own transactions from it now read the same way,
for tx in block.transactions[1:]: coinbase transactions were excluded from the first and not the second, an asymmetry with no consequence — a coinbase is never a mempool entrant — and no explanation either.
finish_sync no longer drops every peer to revise a relay flag¶
A connection’s own
Versionalways asks for transaction relay (#129): Core’sfRelayis about the connection itself — a block-relay-only peer, a feeler,-blocksonly(RejectIncomingTxs,src/net_processing.cpp) — and never aboutIsInitialBlockDownload(), so it never has to be revised once a node catches up.finish_syncno longer callsp2p_manager.stop_all()to get there: every connection already asked for relay from its first handshake, inbound or outbound. A transaction a peer sends before this node has enough of the chain to check it is dropped where it arrives,p2p.callbacks.tx, matching Core’s own early return for the same reason during initial block download.
Every test module under tests/ is named *_test.py¶
Every test module under
tests/is renamed to end in_test.py,tests/unit/’s losing thetest_prefix andtests/functional/’s losing it for the suffix, and the test modules that had been living inside a package’s__init__.pymove out into a sibling of their own (#26). A package’s own__init__.pycannot itself be named*_test.py, sotests/unit/init_test.py,tests/unit/chainstate/init_test.py,tests/unit/p2p/messages/init_test.py,tests/functional/p2p/init_test.pyandtests/functional/rpc/init_test.pycarry what used to live in the package’s__init__.py, andtests/unit/rpc/connection_test.pycarries whattests/unit/rpc/__init__.pytested despitebtclib_node/rpc/__init__.pyitself being empty.tests/unit/helpers.pyrenames totests/unit/helpers_test.pyfor the same reason: nothing imports it, it teststests/helpers.py’s functions, and only its name said otherwise..pre-commit-config.yamlgainsname-tests-testat its default args, enforcing the*_test.pypattern the rename above puts in place.[tool.pytest.ini_options]dropspython_filesandpython_functions: pytest’s own defaults already collect*_test.pyand any function namedtest*, so restating either was a second place for a fact pytest already states, and the one that had drifted —python_files = "*.py"was collecting every module undertests/, which is what let the test modules living inside a package’s__init__.pygo unnoticed as anything other than a package marker.
pytest --strict-config --strict-markers and xfail_strict = true¶
addoptsgains--strict-config --strict-markers, andxfail_strict = truejoins[tool.pytest.ini_options](#31). Every marker this suite uses (pytest.mark.parametrize,pytest.mark.order) is already registered by pytest or by pytest-order, and the suite has noxfailtoday, so both are ratchets at zero cost.filterwarningskeeps its single, namedignoreand does not become["error"]: measured, that setting fails tests scattered across the suite, nearly all on aResourceWarningraised at garbage-collection time against the sqlite connections, sockets, event loops and multiprocessing pools issue #111 and issue #195 already track, plus a hazard neither issue is about — aResourceWarningraised at GC time fails whatever test the collector happens to run during, not the test that leaked, so two runs of the same tree fail a different set of tests. Turning this on is contingent on #111 and #195, not apyproject.tomlline.
feefilter is answered and stored, not thrown away¶
This node sends its own
feefilter(Config.min_relay_feerate, defaulting to Core’s ownDEFAULT_MIN_RELAY_TX_FEEof 100 sat/kvB) once the handshake completes, and a peer’s ownfeefilteris parsed and kept onConnection.feefilter(#94), where it used to be an unknown commandhandle_p2psilently dropped.
An octet past an addr or addrv2 no longer costs the peer¶
A trailing octet past the last address of an
addroraddrv2payload is now silently left unread rather than disconnecting the peer that sent it (#149). btclib’sAddr.parse/AddrV2.parseraise on it (assert_no_trailing, a malleability guard), andmain.handle_p2pturned that raise intoconn.stop(); Bitcoin Core does not disconnect over it. Both accept aBinaryDatastream, andassert_no_trailingskips its own check for one by design (its own docstring: a stream is “the caller’s”), so wrapping the payload in one reaches Core’s leniency without a second copy of either codec.Version.parsetakes narrowerOctetsand cannot be handed the same way without risking its own optional relay-flag byte being misread, so a version carrying a trailing octet still disconnects the peer – the same policy question, answered differently because the two codecs offer different means to answer it. Issue #149’s other half – a relay octet that is neither0x00nor0x01– is above.
getblockcount, getrawtransaction and getblockchaininfo answer¶
getblockcountanswers the active chain’s own height (#21), the active chain’s last index rather than its length.getrawtransactionanswers a mempool transaction by default, and a block named explicitly alongside it (#21) –block_indexandblock_db, read and not indexed again, so no store is added. A transaction confirmed and not named a block is answeredRPC_INVALID_ADDRESS_OR_KEY, the same code Core’s own no--txindexfallback answers (src/rpc/rawtransaction.cpp:313-314), and verbosity is a bool and not Core’sNUM0/1/2:2’s fee and prevout fields need undo data this node keeps nowhere.getblockchaininfoanswerschainalone (#21). btclib’sBitcoinCoreFetcher.assert_networkandbitcoin_core_rpc’sBitcoinCoreRpcClient.assert_chainboth call it before their first fetch by default,verify_networkdefaulting toTrue– measured against a realBitcoinCoreFetcherhere,get_best_block_idfailed-32601 Method not foundon this method, not on the one asked for, before this.SigNethere has no configurable challenge to report thesignet_challengememberassert_chainalso reads on that chain.
RpcManager binds Config.rpc_host, not every interface, by default¶
Configgainedrpc_host,"127.0.0.1"unless a caller asks otherwise, andRpcManager._bindreads it in place of a hardcoded"0.0.0.0"(#27). The RPC port is this node’s control plane, andrpc/callbacks.pyandrpc/connection.pycheck no credential of any kind, so its own default should not be a peer-to-peer listener’s: Bitcoin Core’s ownrpcbind/rpcallowipdefault to localhost for the same reason.P2pManager.server’s own0.0.0.0is unchanged and right to be: a peer listener is supposed to accept a stranger.
A method that is not a string, and a body that is not JSON, both answer¶
is_valid_rpcnow checks thatmethodis a JSON string (#63).handle_rpc’srequest["method"] not in callbacksuses it as a dict key past that check,TypeError: unhashable typefor a JSON array or object there – unanswered since #62’s guard onNode.runcaught the exception, and the whole node’s crash before #62.A body
json.loadscannot parse now answers-32700 Parse error, idnull, JSON-RPC 2.0 section 5.1’s own code for it, in place ofConnection.run’sexcept Exception: self.client.close(), which closed the socket with no answer at all.
active_addresses records a completed handshake, not a closed connection¶
PeerDB.add_active_addressis now called fromcallbacks.verack, once the handshake completes, and no longer fromConnection.stop.stoprecordedself.addresson every close, including a connectioncallbacks.versionhad itself just refused for calling itself, for running an old protocol version, or for missing a required service (#70): a peer this node turned away was gossiped to the next one as good. The address recorded is now the evidence the completed handshake is: the address this node dialled, for an outbound connection, since a socket connecting there already answered; the connection’s own accepted address with the port the peer’s ownversionnames asaddr_from, for an inbound one, sinceP2pManager.server’s own address issock_accept’s source port, the peer’s ephemeral one and never one this node could dial back on — and nothing at all whereaddr_fromnames no port (#70).getaddranswers a connection once.Connection.answered_getaddris set the first time it does; a peer asking again on the same connection gets nothing, where before every ask walked the table again (#71).getaddranswers with a random sample ofactive_addresses, capped the way Core caps its own answer, rather than the whole table sent as however many messages it takes. Serving everything this node knows of to whoever asks is what an observer mapping the network wants; the sample is drawn without replacement from the active table, sized like Core’s ownMAX_PCT_ADDR_TO_SEND(23 percent, rounded up here rather than down so a handful of addresses is still answered with something) and capped atMAX_ADDR_TO_SEND, so one message is always enough (#71).PeerDB.active_addressesis bounded the same wayPeerDB.addressesalready is, at ten thousand entries.get_active_addressesprunes what has gone stale, but only where something still calls it, and a node with enough peers that nobody ever asks agetaddrhad nothing left to call it:add_active_addresshad no bound of its own, so such a node grew the table for as long as it ran (#71).A reconnect to the same endpoint still adds a second row to
active_addressesrather than replacing the first.add_active_addressdoes not settle on one row per endpoint the wayadd_addressesalready does forself.addresses(#247): doing so once per handshake rather than once per batch needs an index kept in sync across every mutation site, not a per-call scan, so it stays open as its own issue (#270).Connection.addressitself moves to the resolved endpointverackcomputes, andP2pManager.manage_connections’s own already-connected check now compares byendpoint_key(address.py’s private_endpoint, renamed and exported) rather than by theNetworkAddressV2dataclasses themselves. A raw-equality check never matched a live connection against a draw of that same peer’s own gossiped address,timestampandservicesdiffering between the two by construction, so once #70 and #71 made a two-nodegetaddrround trip actually work,manage_connectionsredialled a peer this node was already connected to the moment any connection to it ended – including oneponghad just dropped for answering the wrong ping nonce, which is whattests/functional/p2p/ping_test.py::test_wrong_pingstarts asserting by connection id rather than by the manager holding none, since a redial is exactly what now happens next. That a node redials a peer at all right after dropping it for cause, with nothing recording the cause, is left open as its own issue, #283.
REPOSITORY.md’s required-checks section names what main now enforces¶
REPOSITORY.md’s Required checks on main section namesLint and type-checkandtest: every job passed(#223), where it said none did. Classic branch protection’srequired_status_checks.contextscarries both; no ruleset onmaincarries arequired_status_checksrule.
pytest-remotedata and --remote-data=any are gone¶
pyproject.tomldrops thepytest-remotedatadependency andaddopts’s--remote-data=any(#227). #135 removed the only@pytest.mark.remote_datatests the tree had;git grep -n remote_data -- tests/finds none, so neither bought anything left to drop it for.
The mypy configuration’s prose states its decision once, and in the present¶
The move off the hosted
mirrors-mypyhook is argued once, in.pre-commit-config.yaml, withpyproject.toml’s[dependency-groups].lintcomment pointing there instead of arguing it again (#210). Both comments also drop a paragraph on[tool.mypy]not being strict yet, whichstrict = truealready states is no longer so.[tool.mypy]’s comment onshow_error_codesno longer cites btclib-org/.github#170 as an open divergence from the organization’s sample (#228). That issue is closed and the sample no longer sets the key; what stays is why the key is inert here regardless —mypy --helpnames it only as the inverse of--hide-error-codes, which readsFalsewith or without the line.
A peer’s sendheaders now changes what a connected block is sent as¶
callbacks.sendheaderssetsConnection.prefers_headers. Neither dispatch table had asendheadersentry, so the message was silently dropped and nothing recorded the preference Core’s own handler sets aspeer.m_prefers_headers(#202).A block
update_chainadds to the active chain is now sent to every connected peer — asHeaderswhere the peer’s ownsendheadersasked for that, asInvotherwise — once the node is past its own initial sync, the gate the mempool bookkeeping beside it already uses. The onlyHeadersthis node sent before answered a peer’s owngetheaders, and the onlyInvit built was for a transaction (download.py): a block this node accepted reached nobody, by either shape (#202).
A short header batch that connects to nothing known gets a getheaders¶
callbacks.headersasks for what is missing whenever a batch’s tip isNone, whatever the batch’s own length. Thelen(headers) == 2000guard was the only place a follow-upGetHeaderswas built, so a short, BIP130-style announcement whose first header’s parent this node does not know was silently dropped rather than asked for (#233) — unlike Core’s ownHandleUnconnectingHeaders, which asks regardless of batch size.
A header out of parent-before-child order no longer vanishes¶
BlockIndex.add_headersrefuses the whole batch, rather than silently dropping one header, when a header’s parent is itself later in the same batch. A header whose parent had not yet arrived was left out ofpendingwith a barecontinue, and never retried once its parent was processed a few lines later in the same call: neither an exception nor the batch’s own return value said anything had been lost (#214). A peer is not required to send aheadersmessage in strict parent-before-child order, and Core’s own per-message continuity check (CheckHeadersAreContinuous,net_processing.cpp) refuses such a message unconditionally rather than accepting part of it; this now raisesBTClibValueErrorthe same way a header failing its own proof-of-work or context check already does, so the caller can tell the batch was refused. A header whose parent this index has never heard of at all, from this batch or an earlier one, is unaffected: that batch still answersNone, not a refusal (#75).
header_index drops a chain once invalidate has proved it bad¶
BlockIndex.header_indexno longer holds a chaininvalidatehas since marked bad.add_headersweighed a candidate forheader_indexpurely by chainwork, the same way it weighedblock_candidatesbefore #77/#120/#125, so a peer sending more headers onto an already-invalidated fork kept growing what this index reported as its best known header chain, andinvalidateitself never touchedheader_indexfor a chain it already held (#218). Both are fixed the way Core’s ownInvalidateBlock(src/validation.cpp) recomputesm_best_header:add_headersskips a header computed invalid the same way it already skips it forblock_candidates, andinvalidaterescans the whole index to rebuildheader_indexwhen, and only when, the block it just invalidated was part of it.generate_header_index, which shares the rescan withinvalidate, used to buildheader_indexfrom a reloaded database without ever readingBlockStatuseither, so a restart could bring an invalidated chain back as the best known one; it now reads it the same waygenerate_block_candidatesalready did.
PeerDB keeps what it learns, and prefers what answered¶
PeerDBopens aKeyValueStoreatdata_dir / "peers"and reads it back ininit_from_db. Before this,init_from_dbwaspassand nothing underbtclib_node/wrote an address anywhere, so every start was a cold one and the DNS seeds were the only way back in (#123).add_addressesandget_addr_from_dnswrite aknown-row per address they add, keyed on its network id, address and port – not ontimestamporservices, so a row settles on the endpoint rather than growing one per gossip.add_active_addresswrites ananswered-row the same way, carrying the moment this node itself last heard back from that endpoint.data_dirisPath | Nonenow:Nonekeeps aPeerDBin memory only, which is what every caller other thanNodealready did with it.random_addressprefers an address fromget_active_addressesover the uniform draw across the whole table, within a single run and not only across a restart: dialling used to draw uniformly fromself.addressesregardless of whether any of it had ever answered.ask_dns_nodesis decided by what has answered recently, not by whether the table is empty. A tableadd_addressesfilled with tor, i2p or an ipv6-only answer from a seed is not empty and was not one before either, and dialling never had anything in it to draw on – the AAAA-only table #89 describes. It is nownot any(can_connect(a) for a in get_active_addresses()), which also covers the empty case: nothing has ever answered where nothing is known at all.Node.runcloses the peer store on shutdown, alongside the chainstate and the block database.
IPv6 is dialled, and a listener accepts on it too¶
can_connectadmitsBIP155Network.IPV6, anddialopens anAF_INET6socket for it. Both used to answer only forIPV4, so a peer table that held nothing else – a DNS seed answering with AAAA records alone, or a peer that only ever gossiped v6 addresses – had nothing this node would draw and dial (#124).PeerDB.random_addressalready answersNonefor a table with nothing dialable (#89), so a peer whose family this node cannot reach is passed over the same way a peer that is merely slow or refusing is:dial’s existing_DIAL_TIMEOUTbounds the attempt, and a host with no IPv6 route fails it the same way. No separate reachability check was added for that case – Bitcoin Core’s own default is “everything is reachable” (ReachableNets,src/netbase.h), leaving an unreachable family to fail its own connection attempts rather than being detected ahead of one.P2pManagerbinds an IPv6 listener beside the IPv4 one,::withIPV6_V6ONLYset so that a v4 peer is never accepted on it wearing its address mapped into sixteen octets. The IPv6 bind is not required forrunto succeed: a host with no IPv6 support fails only that one, and the IPv4 listener above is what a caller ofruncan still rely on – Core’s ownInitBindstreats its “::” the same way. An inbound peer’ssockaddris sliced to its host and port before becoming aNetworkAddressV2, anAF_INET6one carrying two fields BIP155 has nowhere to put.
A BIP155 IPv6 record that embeds another network is not kept¶
PeerDB.add_addressesdrops anIPV6record whose sixteen octets are::ffff:0:0/96, the IPv4 mapping, or OnionCat’sfd87:d87e:eb43::/48, once how a TORv2 address was embedded in a fake IPv6 one. BIP155 says a client should ignore both; before this the table kept whatever anaddrv2carried, so a v4-mapped entry sat under network id 2 andaddr_entrylater wrote it into anaddrversion 1 message using the same sixteen octets an ordinary IPv4 peer uses – a peer reading it back saw IPv4, and the same host was two entries in the table (#151).btclib.p2p.addrv2’s own docstring calls both rules receive policy left to the caller, which is why the check is here and not in the codec.
The coverage floor knows what asked for it, and what erases it¶
relax_coverage_floor’sasked_forreadsconfig.option.cov_fail_underinstead of scanningconfig.invocation_params.argsfor a--cov-fail-underprefix (#180).invocation_params.argsis only what was handed topytest.main; pytest splicesPYTEST_ADDOPTSin afterwards, so a floor asked for that way never appeared in the scan and was silently stood down to 0 on any runPYTEST_ADDOPTSnarrowed.option.cov_fail_underis argparse’s own parsed result and carries the flag regardless of which of the two wrote it.CLAUDE.mdnames the coverage data a secondpytestinvocation erases, and the environment variable that keeps it out of reach (#191).pytest-cov’s owntryfirsthook onpytest_load_initial_conftestscallscov.erase()before this tree’sconftest.pyis even imported, so nothing at this repository’s pytest configuration surface intercepts it; under-n autothat erase sweeps every parallel-suffixed file in the rootdir, including a running suite’s own workers’, and--helpreaches it too.COVERAGE_FILEis read bycoverage.pyfrom the environment rather than through that hook chain, so pointing it outside the rootdir avoids the collision entirely; a same-prefix name still inside the rootdir does not, since a concurrent plain invocation’s ownerase(parallel=True)globs its base filename plus.*in that base’s directory regardless of what name the protected run chose.
get_cfilters stops once the connection it is answering has closed¶
get_cfilters’s loop over agetcfiltersrange now breaks onceconn.status == P2pConnStatus.Closed.Connection.async_send’s send-buffer bound (#101) can drop a connection partway through agetcfiltersanswer; before this, the loop kept serializing aCFilterand scheduling a send for every height still left in the range regardless, none of it ever reaching a peer whose socket is already closed (#239).get_cfheadersandget_cfcheckptbuild their one answer across their own loop and callconn.sendonce at the end rather than per height, so neither has a doomed send mid-loop to skip.
BlockInfo.chainwork moves off the serialized record¶
BlockInfo.serializeand.deserializeare inverses again.chainworkused to be a field onBlockInfothatserializenever wrote anddeserializeleft at its default, so a round trip through the two was not the identity for a block whose chainwork was not zero (#201). It is no longer a field: chainwork lives inBlockIndex.chainwork, adict[bytes, int]keyed by hash thatcalculate_chainwork’s start-up walk writes into directly, so a start does not rebuild oneBlockInfoper header just to attach a derived value.rpc/callbacks.py’sgetblockheaderreads the same value from there now.
A shutdown mid-reorg is noticed between blocks, not once per fork¶
update_chainreadsterminate_flagbefore starting each block ofto_add, not only at the top ofNode.run’s own loop. The waitNode.stopbounds used to scale with the whole fork:check_transactionsis a blockingworker_pool.starmapover one block’s inputs – on mainnet, thousands of signature checks – and nothing insideupdate_chainread the flag, so a shutdown requested during a deep reorg could outlastSTOP_TIMEOUTand be reported as a node that would not stop, when it was validating correctly (#139).STOP_TIMEOUTitself is unchanged: it already had to cover only what one pass of the loop could take, and what one pass can now take is one block rather than a whole fork.Stopping there rolls the trial back the way a failed block does, without marking any block invalid. Nothing
update_chainbuffers along the way – the utxo set, the reverse patches, the compact filters – reaches disk before every block of the fork has validated, so there is no partial state for a stop to leave behind: the chainstate is exactly where it was before the call started. What distinguishes a stop from a block that failed its own check is thatupdate_header_indexis never called for it – a shutdown is not the block’s own defect, and the candidate is offered again on the next run.
A reverse patch is filed with its own block, once its branch connects¶
BlockDBresolves the.revfile a patch goes in from the block it undoes, not from whichever.blkfile happens to be open when it is written.add_rev_block’s target used to beself.file_index, the block file currently being written, so a patch and the block it undoes could land in files with unrelated numbers.BlockDB.finalizenow reads the block’s own stored location instead, so the two always share a file number (#116). A patch already on disk keeps whatever file it was written to –get_rev_blockreads back through the location recorded for it rather than deriving one from a block’s own file, so nothing already stored needs moving.add_rev_blockbuffers what it is given, andBlockDB.finalizeorrollbackdecides whether it reaches disk – the same patternUtxoIndexandFilterIndexalready hold their own writes under.update_chaincallsfinalizeonly for the branch that connects androllbackfor the one it refuses, where before, a patch reached disk as soon as its own block validated: the earlier, validated blocks of a branch whose tip then failed kept their reverse patches on disk with nothing left pointing back to them (#200).
Header sync tells a refused batch from an empty one, and moves on¶
BlockIndex.add_headersraises on a batch it refuses instead of answeringFalse, the same answer it gave a batch that carried nothing new. A header failing its own proof of work or a contextual check is a peer that sent something invalid, not a peer with nothing further to offer, andcallbacks.headersno longer treats the two alike: the raise reacheshandle_p2p, which drops the connection the way a bad block’s raise already does (#75).add_headersreturns the hash of the highest header in the batch now indexed instead of a bool.header_indexonly moves for a header that extends it or beats its chainwork, so a fork arriving below the active chain’s tip left it where it was; the nextgetheaderslocator asked for the same batch again, and the sync stopped short of the fork’s own tip (#122).callbacks.headersnames that hash in a full batch’s nextgetheaderslocator only for a live fork belowheader_index’s own tip. An ordinary batch extendingheader_indexkeeps its richer, multi-entry locator, which already reached that case; a batch built on a header this node has already proved invalid does too, rather than asking the same peer for more of a branch already proved bad, with no misbehaviour scoring anywhere in this tree to ever stop that otherwise (#75, #122).
A connection is not reachable by any send until its handshake finishes¶
P2pManagerkeeps an accepted or dialled connection in a newpending_connectionsdict untilcallbacks.verackpromotes it intoconnections(#131), which is where every send that used to reach a handshaking peer read from:broadcast_raw_transaction,ping_all, the housekeeping sweep’s own ping, andDownloadManager’s sends over the same dict. Before, a new connection went straight intoconnections, so anInvor a rawTxcould reach a peer before its ownversion/verackexchange was done, which the protocol treats as a violation.The housekeeping sweep still closes a connection stuck mid-handshake, without pinging it first:
pingis itself one of the messages a connection cannot be sent beforeverack, so a pending connection past the same idle boundconnectionsare held to is dropped once quiet rather than pinged and given a second window to answer.stop_alland the manager’s ownstopstill close a pending connection, and the housekeeping loop’s dial count and already-connected check both read frompending_connectionstoo, so a peer mid-handshake is neither left dangling on shutdown nor dialled a second time.getconnectioncountstill counts a peer mid-handshake, matching Core’s ownGetNodeCount, which counts every entry ofm_nodesand not only the ones that finished negotiating.
Connection bounds what it queues to write, and drops a peer past it¶
A peer answered with more than
Connectionwill queue is dropped rather than left to grow the queue further (#101).getcfilters, bounded to 1,000 answers per request by_filter_range, is the message the issue names: nothing stopped a peer from pipelining a second request before the first’s answers had gone out, so the per-request bound did not bound what a peer could have outstanding at once.btclib_node/p2p/connection.py’sasync_sendnow tracksqueued_send_bytes, every serialized octet handed toward the socket and not yet written, and refuses to queue a message that would push the total pastMAX_QUEUED_SEND_BYTES, callingstop()instead.The bound is BIP157’s own traffic, not Core’s
-maxsendbufferdefault. Core’s cap (1,000,000 bytes) is where Core starts pausing, not a size any one answer is held to – its own send queue for an in-progressgetcfiltersanswer routinely exceeds it while paused, because the per-request bound alone reaches tens of megabytes. This node drops instead of pausing (below), so its own number has to accommodate a whole legitimate answer rather than start throttling where Core’s does.MAX_QUEUED_SEND_BYTESis derived, not copied: measuringbtclib’s own Golomb-Rice filter encoder puts a filter element at about 2.632 bytes regardless of scale, a real mainnet block (height 481824, btclib’s own test fixture) anchors what one busy block costs at around 24.5 KB of filter, and four times that block’s element count stands in for a block nearer this node’s own present – about 98 KB.MAX_GETCFILTERS_SIZE(1,000) of those is one legitimate answer at its largest, about 98 MB, the tens of megabytes the issue itself measured;MAX_QUEUED_SEND_BYTESis twice that, room for one answer to drain in full and a second one – pipelined behind it, or simply the next request – to be under way as well.Dropping the connection instead of pausing it, unlike Core’s own choice for a full send buffer. Core’s
ProcessMessagesandProcessGetData(net_processing.cpp) each checkfPauseSendbefore generating another message for a peer over budget, leaving what is already queued to drain and resuming the next call; this node has no message-processing stage separate from the handler that callssendonce and is done, so there is no later call to resume at. Refusing to queue further and dropping the connection is what the same backpressure comes to here.The writes themselves are now serialized through a lock,
Connection.send_lock: twoasync_sendcalls both past the point whereloop.sock_sendall’s own first, synchronoussock.sendcould not take everything would otherwise register on the same file descriptor, andBaseSelectorEventLoop._add_writercancels whichever of the two was already waiting rather than queuing behind it — a second message’s bytes reaching the peer ahead of the first’s remainder, on the same stream.Connection.stopis idempotent. Several messages queued at once can each independently discover the connection is over budget before any of them has changed anything a later one could check instead, so more than one can callstopfor the same connection; a second call now does nothing rather than tellingpeer_dbabout the same address twice.
Connection.__repr__ spells a peer’s endpoint through ip_and_port too¶
btclib_node/p2p/connection.pyandbtclib_node/rpc/connection.pyboth format their__repr__throughbtclib_node/p2p/address.py’sip_and_portinstead off"{peer[0]}:{peer[1]}"(#209). A peer at2001:db8::1on port 8333 now reads[2001:db8::1]:8333in a log record or a traceback carrying either connection’srepr, where the hand-written spelling gave2001:db8::1:8333. Every p2p socket isAF_INETand the RPC listener binds0.0.0.0, so what either prints for a peer this node can have today does not move.
A lint hook, not .gitattributes, catches what union merges in silence¶
merge=unionstays (#199):btclib-org/.github#21decided against dropping it — a guaranteed conflict on every pull request appending to a group, in a file where that conflict has nothing to decide, is a worse trade than the rare silent one, andgit merge-tree --write-tree --messageson two commits that each add a###heading at the same point shows dropping the attribute does exactly that: the merge that used to exit 0 now exits 1 on every such pair, not only the ones that drop a blank line. Doubling the trailing blank line of every entry does not survive the same merge either — the boundary between two branches’ own new headings still collapses to none — and a custom merge driver needs a section in the local, unversioned.git/configthat no.gitattributesentry can supply, so neither is a repository-versioned alternative.A local
pre-commithook now runs the check by hand instead of requiring somebody to run it by hand (#199):changelog-heading-blank-linefails on any###line inCHANGELOG.mdnot preceded by a blank one, whichmarkdownlint-cli2’s own MD022 does not catch here since it is disabled for this file. It runs as part of the sameuv run pre-commit run --all-filesa rebase already asks for, not as an installed git hook:CONTRIBUTING.md’s The gate is not installed as a git hook is why,.git/hooksbeing shared by every worktree of this repository.The headings union had already collapsed are restored (#194): the blank line is back before
links.yml’s own heading and beforegetrawmempool’s.The
REVIEWING.mdentries filed underenable_error_code’s own heading move to one of their own (#194), ahead ofenable_error_code’s remaining bullet.
A branch this node has proved bad stops being offered¶
BlockIndex.invalidateis the one place a block’s invalidity is recorded, and what it costs: the block itself and every header this index has already indexed on top of it, candidate or not, are markedBlockStatus.invalidand dropped fromblock_candidateswhere present – a newchildrenmap, the reverse ofprevious_block_hash, is what the walk costs the size of the bad lineage rather than the whole index.add_headersrefuses to build avalid_headeron a parent already carrying that status, so a header arriving afterwards inherits it without a walk of its own (#125).A block whose
assert_validraises is invalidated before the peer that sent it is dropped, so the next peer offering it is refused before being asked to send it again (#77).update_chain’s failure path invalidates the block whose contextual check raised rather than leaving every header built on it a candidate forever (#120).get_first_candidateasks whether a candidate’s whole branch has arrived, not just its tip. A hole behind a downloaded tip used to pass that check, andupdate_chainthen gave up its whole pass on the hole, leaving the same candidate at the front of the queue every time; a complete branch further back could not connect until it filled. It is stepped over now, the way a branch missing its tip already was (#121).
[tool.mypy] sets nothing mypy already has on¶
show_column_numbersis set, so an error message names the column it starts at and not the line alone (#173): the position reads2:14where it read2. Section 6 of the organization’s standard samples the setting and mypy leaves it off.strict_bytesis gone: mypy has it on, and the line bought no check (#182).show_error_codes, which section 6 samples too, is of that same kind and is not added; btclib-org/.github#170 is the divergence from the sample, and the comment above the settings now carries the command that reads one back out of mypy’s parsing of this file, so either claim is one line to check.The comment above
enable_error_codenames no code as one mypy has on (#190). The codes it named read as the whole of mypy’s default-enabled set, where the command beside them is what decides any code.The mypy hook’s comment in
.pre-commit-config.yamlstates no count for the hosted hook it replaced (#162). Nothing here configures that hook, so no command in this tree re-derives the number.
A peer’s endpoint is ip_and_port’s spelling, in the log too¶
The
Connected toline the handshake writes goes throughbtclib_node/p2p/address.py’sip_and_port, and asks the socket for the peer once (#189). A peer at2001:db8::1on port 8333 reads[2001:db8::1]:8333, where the hand-written spelling gave2001:db8::1:8333, the host running into the port with nothing between them to say which is which. Every p2p socket isAF_INETanddialrefuses every network butBIP155Network.IPV4, so what is written for a peer this node can have today does not move.P2pManager.servercallsloop.sock_accept’s answersockaddr(#185), which is whatPeerDB.get_addr_from_dnscalls the same pair. The name it had was the formatter’s own, and the assignment made that name a local ofserverfor the whole function: importing the formatter and calling it there would raiseUnboundLocalErrorbefore the accept.
A comment says why the code is as it is, in words a reader can look up¶
The comment on
get_peer_info’s broadexceptgives the reason the exception is swallowed and stops there (#193). What it deferred to was a bandit# nosec B112suppression thatgrep -rn nosec btclib_node testsdoes not find.The comment on
callbacks.version’s protocol-version check spellssimplicity(#203). Neithercodespellnortyposreports the Italian spelling it replaces, so the lint gate catches no such word.
The root prose and the editor configuration describe this repository¶
README.mdopens with the namepyproject.tomldeclares (#158).btclib_nodeis the import package andbtclib-nodeis the project, and the heading, the opening sentence and the bold line say the second. The link on that opening name is dropped rather than repointed:https://github.com/btclib-org/btclib_nodeanswers301to the address the Source line already carries, and a redirect resolves only while nobody else claims the name it redirects from.REPOSITORY.mdrecords what its calls answer and compares this repository to no sibling (#157). Private vulnerability reporting answers{"enabled":true}, so the advisory route the organization’s security policy sends a reporter to is open here, where the file said it was off and that siblings had it on. The wiki and the projects board are on and the standard states no rule about either, which makes them this repository’s own answer rather than the divergence the file called them;git ls-remoteagainst the wiki is what says it holds nothing..vscode/extensions.jsonjustifies each entry by a hook this repository runs (#161)..pre-commit-config.yamlcarriesactionlintandzizmor, which read.github/workflows/, where the comment ongithub.vscode-github-actionssaid no hook read a workflow at all and named a single file there. The header’s account of what a sibling has and this tree does not is dropped:.github/dependabot.ymlis in the tree with acheck-dependabothook over it, and the rule the list is kept to is the sentence above it..vscode/settings.jsonstates no hook list and no path list (#161, #163). The enumeration of what no extension wraps was wrong in both directions —yamllintwas in it whileextensions.jsonrecommendsredhat.vscode-yamlfor that hook — and what stands in its place is that a hook with no entry inextensions.jsonis seen by the gate alone. The pyright survey dropsbtclib_node tests: pyright excludes every hidden directory by default, so with no paths it reads the directories[tool.mypy]’sfilesnames,scriptsamong them.
A header is checked for the target and the time its height requires¶
BlockIndex.add_headersrunsassert_valid_in_contextbesideassert_valid_pow(#118), inbtclib_node/chainstate/contextual.py: the compact target the header’s height requires,next_bits_required(Core’sGetNextWorkRequired), and that its timestamp is later than the median of the eleven ancestors before it,median_time_past(Core’sGetMedianTimePast). Before this,assert_valid_powasked only whether a header’s hash met the target the header itself claimed, so a header claiming any easier target within the network’s limit was credited that chainwork regardless of what the chain at that height required.Chaincarriespow_allow_min_difficulty_blocksandpow_no_retargeting, Core’sfPowAllowMinDifficultyBlocksandfPowNoRetargeting, each set once per network inMain,TestNet,SigNetandRegTest’s own__init__.A header’s parent may be another header earlier in the same batch, not only one already in the index:
add_headersweighs each header against what came before it in its ownheadersmessage before any of the batch is indexed.Left unchecked: the BIP94 timewarp rule, which holds only on testnet4 and on a regtest run started with
-test=bip94, neither of which this node offers, and the version-floor checksContextualCheckBlockHeaderalso makes.
A match statement has to cover the type it matches on¶
exhaustive-matchjoinsenable_error_code, so amatchleaving a member of its subject’s type unhandled is an error (#188). mypy leaves that code off and no flagstrict = truesets turns it on, so without the entrydef f(v: int | str) -> str: match v: case int(): return "i" return "?"
type-checks.
mypypasses overfileswith the entry as it does without it, and what the entry answers is the firstmatchwritten there.The comment above the list answers for
unused-ignoretoo, which stays outside it.
A read of the block index is not a write of it¶
BlockInfois frozen, so whatBlockIndex.get_block_infohands out is the index’s own record and a caller cannot assign to its fields (#117). Assigning to one is refused by the type check,Property "status" defined in "BlockInfo" is read-only, where it changed the index in place, reaching neither the write batch nor the database.headeris btclib’s own dataclass and is not frozen, so that one field is still a caller’s to change.BlockIndex.set_statusandBlockIndex.set_downloadedare how the fields a caller changes are changed. Each reads the record the index holds and writes its replacement to memory and to the database in one call, so a copy that has gone stale cannot be written back over one that has not.insert_block_infois private to the index andmain.update_block_statusis gone.update_chainwrites no status while a branch is being tried. The status set on the trial path reached the database as the trial ran and refusing the branch did not take it back: a fork whose tip prints money left the blocks below that tip atvalidin the database, where the utxo set the same pass wrote was rolled back. The database write moves into the batchupdate_chaincommits on success, where the rest of the chainstate already wrote.
The command-only messages are btclib’s¶
getaddr,mempool,sendheadersandwtxidrelayarebtclib.p2p.negotiation’sGetAddr,Mempool,SendHeadersandWtxidRelay, andbtclib_node/p2p/messages/empty.pygoes with the copies it held (#196). btclib defines the wire format these commands travel under, so a codec for them kept here is one this tree has to hold in step with a format it does not own.btclib_node/p2p/messages/defines BIP61’srejectalone. Bitcoin Core’sNetMsgTypehas no entry for that command andbtclib.p2pcarries no codec for it.btclib.p2p.negotiationdeclaresFeeFilterbeside the four taken here. Neitherfeefilternormempoolis a key in this node’s dispatch tables, and #94 is where the first is wired up.
REVIEWING.md is the organization’s copy¶
REVIEWING.md’s The gates are the evidence excepts no gate from the run a reviewer may rely on, the test suite included. The organization’s copy, shared half byte for byte (section 14): a run is whole whoever makes it — never a module on its own, a-k, a--lf, a deselect or a marker in its place — and one that was narrowed or cut short is reported as no run (btclib-org/.github#168).REVIEWING.mdis the organization’s copy. A review reads the prose that stays in the tree, treats a commit message or a pull request’s body as a finding only where it decides something, and asks a stated count, a measurement nothing re-derives, or the history of the code told in a comment to go — section 14 of the standard, the shared half byte for byte.
enable_error_code holds only codes that need enabling¶
comparison-overlap,import-not-foundandimport-untypedare not added toenable_error_code, andnarrowed-type-not-subtypeleaves it: mypy has each of them on already, so an entry naming one buys no check (#175). Each answersTruetouv run --locked --no-default-groups --group lint --group test \ python -c "import mypy.errorcodes as m; \ print(m.error_codes['import-untyped'].default_enabled)"
where every code the list still holds answers
False. So under the mypyuv.lockpins,filesis already checked under all of them, and the survey #175 asked for ends in there being nothing to enable rather than in a candidate enabled and measured at zero.Nothing changes about what
mypyreports. What changes is that the list is shorter and its comment now states the condition an entry has to meet, which is what keeps these from being proposed again.
links.yml asks lychee a question its flags let it answer¶
--acceptis lychee’s default range with429added, where it was200,206,429. The flag replaces the default rather than adding to it, andlychee --helpgives that default as100..=103,200..=299: the list passed here turned a201or a204into a dead link to add a206the default already covered, and a host that starts answering204to a HEAD would have turned a live link red without anybody touching the tree. The celltests/links_test.pyof btclib-org/.github reported on this repository against btclib-org/.github#110 goes from the backlog.links.ymlno longer passes--cache. No step restored the cache file between runs, so the flag decided nothing across them, and it would decide nothing with the step added: the run is weekly and the cache age passed beside it was a day. Within one run lychee asks each URL once whatever the flag says —lychee --offlineover this tree’s globs reports the unique count beside the total, the same pair with the flag and without it. The celltests/links_test.pyreported on this repository against btclib-org/.github#111 goes from the backlog.
The root files are the organization’s, and the tree says which¶
RELEASING.mdandRELEASE_NOTES.mdare gone, and what the first said that is worth keeping is where a contributor reads. Section 2 of btclib-org/.github’s README says a tier-2 repository carries neither (btclib-org/.github#150): the first opened There is no release, and no machinery for one in a tree with a release page onv0.1.0, and the second had nothing to be on top of, its one section reading Nothing to act on. What stays — that nothing is on the index and what anybody runs is a checkout ofmain, that a tag is signed andtag-integrityrefuses one that is not, that## Unreleasedbecomes the version and a tag can be deleted and re-cut only while nothing is published — isCONTRIBUTING.md’s A version, and no release, under This repository in particular. The list of what a release waits on is not carried over: section 2 weighed a procedure kept ready for a release that may come and decided that it arrives withrelease.ymlthe day it does.README.md’s pointer to the release notes is a line saying there is no release,REPOSITORY.md’s No publishing bullet cites that section where it citedRELEASING.md, and this file’s introduction stops naming a record it is behind. The entry below that lists the two files among those arriving describes the tree between that landing and this one..gitattributeskeepsRELEASE_NOTES.md merge=union: section 14 of the standard owes the twomerge=unionentries to every copy, and an attribute on a path the tree does not hold is inert.CONTRIBUTING.mdandREVIEWING.mdare the same file as every sibling’s down to## This repository in particular. Section 14 of btclib-org/.github is what says so andtests/verbatim_test.pythere is what compares the bytes. What each said in this tree’s own words about rules that are the organization’s — the tracker, the prose style, the pull request, the review, the landing — is gone, a second wording being the copy that goes stale; what is under the marker is what only this tree can say.CODE_OF_CONDUCT.md,AUTHORS.md,SECURITY.md,REPOSITORY.md,RELEASING.md,CHANGELOG.mdandRELEASE_NOTES.mdarrive, which is most of issue #34.AUTHORS.mdpoints at this repository’s own contributor graph rather than a sibling’s.SECURITY.mdsays what is this node’s to answer for as against btclib’s and Bitcoin Core’s, and gives an address because private vulnerability reporting is off here (#136).REPOSITORY.mdis every setting read back from the endpoint, including the one that says no status check is required (#88 of btclib-org/.github)..markdownlint.jsonc,.yamllint.yaml,.taplo.tomlandLICENSEare byte-for-byte the organization’s copies, this having been the one repository where the three configurations still differed..yamllint.yamlis the one that changes behaviour: it extends the default set where this copy listed two rules and extended nothing, so indentation, trailing whitespace and duplicate keys were unchecked under a gate that passed for having nothing to check, anddocument-startmoves from a warning the hook exits 0 on to an error. No tracked yaml file here trips the wider set.LICENSEgains itsMIT Licensetitle and loses the year range thatCOPYRIGHThas never carried.CLAUDE.mdholds what no document written for a human can — the architecture, the worktree rule, the model, the facts that otherwise cost a session. The environment and the gates moved toCONTRIBUTING.md’s last section, a human having no reason to open an agent’s file to learn how to run one..gitattributesmarks the two new record filesmerge=union. It was empty; without those lines every branch appending a bullet would conflict on the insertion point, which is a conflict with nothing to decide..gitattributesis byte-for-byte the organization’s copy. The attributes it sets do not change:git check-attr merge CHANGELOG.md RELEASE_NOTES.mdanswersunionfor both before and after; what changed is the comment above them, which had been reworded here and did not point at the section of the standard that records the rule. Section 14 of btclib-org/.github is what makes the file one of those compared, and btclib-org/.github#102 is the sweep.README.mdpoints at those files rather than repeating them. It carried the install, test and lint commands, which are nowCONTRIBUTING.md’s last section: two copies of a command are two things to keep in step, and the one a contributor reads is the one CI runs.
.pydeps and the dependency that read it are gone¶
Nothing ran
pydeps. The configuration file was in the tree and the package was in thetestdependency group, and no hook, workflow, script or test invoked either —grep -rn pydepsnamed the two of them and nothing else. A tool that only a person can remember to run is not a gate, and its configuration is a file a reader has to account for.
macos.yml is os-macos.yml¶
The platform sweeps carry a prefix that groups them. The file is renamed, its
name:key with it; the job name is untouched, a check being keyed by name alone and bound outside the tree. Nothing in the repository refers to the old path.
scripts/ was in the mypy gate’s own configuration, and never in the gate¶
The pre-commit hook passed
btclib_node testson its command line, which overrides[tool.mypy]’sfilesrather than agreeing with it — mypy readsfilesonly when given no paths of its own.filesnamedscriptsand said so in a comment; the gate never checked it, the same shape of defect.yamllint.yaml’s entry above records for a config that extended nothing. The hook now passes no paths, sofilesis the one list both a baremypyand the gate read, and cannot drift from each other again. The stale per-flag error counts the same[tool.mypy]block carried are replaced with the command that re-derives them: a count is a line every branch touching that block has to keep true, and this one had already gone false.
The build backend is uv_build, and what the sdist carries is declared¶
setuptools.build_metabecomesuv_build, pinned>=0.12.5,<0.13;[tool.setuptools.packages.find]goes with it, andmodule-root = ""is what now says the package sits at the root. btclib-org/.github#118 is where a pure-Python project was decided onto that backend, and btclib is the tree the pin comes from. The property the floor is about — the sdist’s ownpyproject.tomlbeing a normalized copy with the verbatim one beside it aspyproject.toml.orig— arrives in0.12.0, so the pin sits above that boundary rather than on it:0.12.5is the uv this tree is gated against, being the rev.pre-commit-config.yamlpins foruv-pre-commit.[build-system]carries the command that locates the boundary, and whyuv buildis not it.What the sdist holds is stated for the first time. There was no
MANIFEST.in, so the archive was whatever setuptools defaulted to and nothing had declared it — and what it left out was the suite, and every configuration file of the lint gate that is a file of its own.pyproject.tomlwent, so the tools reading their settings from it were served and the hooks keeping a file of their own were not.[tool.uv.build-backend]source-includenow names the root prose,scripts/,tests/,uv.lock,.python-versionand the configuration each hook keeps..github/is left out as a decision and not because anything refuses it: adding.github/**ships the directory andcheck-sdiststill reports the archive as matching git, that tool’s default ignore list suppressing a report rather than governing what is packed.The archive is not a tree the gate runs on, which the include list raises and does not settle:
git initin an unpacked sdist followed bypre-commit run --all-filesexits 1 atcheck-hooks-apply, and that run names the hooks left with no file to read. Shipping.github/does not change the verdict — the same run still exits 1, on the hooks whose files are the workspaces[tool.check-sdist]keeps back.
The review check is red on anything but an ack of its head¶
claude-review.ymlreads the verdict the review posted, and fails on anything but anACKnaming the pull request’s head. The one guard it had tested whether the action had started, and that was the whole of the check’s colour: on pull request 164 the run for4da7fba, a sha the review answeredCHANGES REQUESTED, concludedsuccess. The step is btclib-org/.github’s, taken from itsclaude-review.ymlat18e6c64with the comment that carries its measurement; btclib-org/.github#146 is the finding across the organization. Still not a required check, for the reason the file’s header gives.
Two root files are the organization’s, and no longer this tree’s¶
CODE_OF_CONDUCT.mdis gone. It pointed at the PSF code of conduct, as the one copy in btclib-org/.github does, and GitHub shows that copy for a public repository that has none of its own: a copy per repository is a copy of a pointer, which is why section 14 of the standard no longer lists the file (btclib-org/.github#123).SECURITY.mdis gone, and its Limitations are inREADME.md. The policy a tree keeps is the one that travels in its sdist, and this repository publishes nothing for one to travel with, so what GitHub shows is the organization’s (btclib-org/.github#116). What that policy cannot say for this node — the JSON-RPC listener on0.0.0.0authenticating nothing, #27, and what a peer may ask for not being bounded by what asking costs it, #101 — is under its own heading inREADME.md, which is where somebody about to run the node reads.RELEASING.md,REPOSITORY.mdand the issue template’s contact link pointed at the file, and now point at the policy shown here.The wheel carries the same members, less
top_level.txt, which is setuptools’ own legacy metadata rather than something the wheel specification asks for. The member lists are what is unchanged; the metadata files themselves are rewritten by the new generator, gaining a field as well as losing one —METADATAgains anAuthor:beside theAuthor-email:setuptools emitted alone, drops theDynamic: license-filesetuptools added, and reorders the rest, whileWHEELnamesuvwhere it namedsetuptoolsandRECORDis reordered. Adiffover the two unpackeddist-infodirectories is what shows it, and no summary here stands in for that. What is unchanged is what the wheel installs: every.pyis byte-identical,RECORDgiving each the same hash and length as before. To re-derive either archive, build in a checkout of the commit before this one and in one of this commit:uv build -o dist # in each checkout diff <(tar tzf <old>/dist/*.tar.gz | sed 's|^[^/]*/||;s|/$||' | sort -u) \ <(tar tzf <new>/dist/*.tar.gz | sed 's|^[^/]*/||;s|/$||' | sort -u) diff <(unzip -Z1 <old>/dist/*.whl | grep -v '/$' | sort) \ <(unzip -Z1 <new>/dist/*.whl | grep -v '/$' | sort)
The directory members are dropped there because the two backends record them differently: setuptools ends an sdist’s with a slash where the uv backend does not, and the uv backend writes them into the wheel where setuptools writes none. That is a difference in how the archive is recorded and not in what it carries.
check-sdistjoins the lint gate, which is the first thing here to ask what is tracked and not in the archive, and what is in the archive and not tracked. It reads[tool.uv.build-backend]for the uv backend, so an exclusion there is not reported again;[tool.check-sdist]git-onlyholds what no include pattern adds — the workspaces of an editor and of an agent, the files git reads, andCOPYRIGHT, which btclib-org/.github#135 argues is a repository file rather than a distributed one.
Every signature in btclib_node, tests and scripts is annotated¶
disallow_untyped_defs,disallow_untyped_callsanddisallow_incomplete_defsjoin[tool.mypy]’s enabled bundle, andexplicit-overridejoinsenable_error_code— the four #104 was the annotation pass for, each measured at zero overfilesbefore being turned on. Annotating a signature is what most of it took; where a test built aSimpleNamespacein place of a real class, the fix is one of three named patterns and not a fourth invented per call site —castwhere a production constructor is handed the double,Anywhere the double is never handed to one, a localProtocolwhere several callers share a narrower shape than the real class has. Annotatingbtclib_node/db.py’s_rowssurfaced that its return type was narrower than thePRAGMAqueries already run through it;Config.log_pathwas declaredstrwhere its own__init__treats a falsy value as “no file logging” and everyscripts/chains/*.pyscript relies on exactly that. Both are widened rather than worked around.no_implicit_reexportandcheck_untyped_defsare untouched by this pass and stay off for the reasons[tool.mypy]’s own comments give.
An RPC connection is forgotten once its answer is sent¶
RpcManager.connectionsno longer grows for the life of the node (#64). Every request opened a connection,async_sendanswered it and closed its socket, and the entry stayed in the dict anyway: measured onorigin/mainbefore this change, a run of eleven requests against a running node leftlen(connections)at 11 and climbing, never smaller. The removal was already written intorpc/main.py, commented out at the end ofhandle_rpc; restoring it is the whole of the fix, placed after the answer is sent on both paths – aftersend_and_waitforstop, aftersendotherwise.RpcManager.remove_connectionis deleted rather than repaired. It called.stop()on aConnection, which has no such method (close()is what it defines), and nothing had ever called it. Wiring it up inhandle_rpc’s place would have calledConnection.close()– which closes the socket synchronously, fromNode’s own thread – while the still-scheduledasync_sendonRpcManager’s event loop thread can be mid-write on the fire-and-forgetsend()path that never waits for it: a cross-thread race that can cut an answer off before it reaches the client. The restored line only forgets the dict entry; the socket is closed byasync_senditself, on the thread already writing to it.btclib_node/p2p/manager.pykeeps its ownremove_connection, which is safe for the same reason this one was not: it is only ever called from inside its own manager’s event loop, not across threads.The RPC port binds every interface with no authentication (#27), so this was a leak any client could drive, not only an internal bookkeeping detail.
no_implicit_reexport is on: every import names where a name is defined¶
TxIn,TxOutandOutPointwere imported from a module that only passes them through.TxInandTxOutcame frombtclib.tx.tx, which imports them for its own use and defines neither;OutPointcame frombtclib.tx.tx_inthe same way.btclibalready answers this correctly at its package boundary –btclib.tx’s own__init__.pyre-exports all three explicitly,__all__and all – the gap was entirely on this side, every affected import redirected to where each name is actually defined:OutPointinbtclib.tx.out_point,TxIninbtclib.tx.tx_in,TxOutinbtclib.tx.tx_out.uv run --locked --no-default-groups --group lint --group test mypy --strictis clean where it was not before.no_implicit_reexportmoves into the enabled bundle – the last of the flags[tool.mypy]’s comments had left off pending this fix,check_untyped_defs(#105) being the one still open.
[tool.mypy] is strict = true¶
The itemized strict bundle is gone.
[tool.mypy]used to enumerate every flagmypy --help’s--strictentry bundles, one at a time, with the case against turning it on wholesale beside it: the organization’s standard now requiresstrict = trueoutright, with no trajectory-toward-it exception (btclib-org/.github#112), which makes the itemized shape a non-conformance rather than a deferred step of #30.warn_unused_configs,strict_bytesandwarn_unreachableare not in that bundle and stay their own lines.check_untyped_defs, the one bundled flag the table still left off, measures at zero and closes #105:uv run --locked --no-default-groups --group lint --group test mypy --check-untyped-defsreports no issues in 82 source files, #104’s annotation pass having left nothing infilesfor it to check.[project.classifiers]’s reason for omittingTyping :: Typedno longer cites mypy not being strict, and thelintdependency group’s comment above[tool.mypy]points atstrict = truein place of the per-flag measurement it used to describe – both assumed the itemized shape this removes.
getrawmempool’s verbose output no longer misspells its weight key¶
The JSON-RPC key a client reads for a mempool transaction’s weight is now
weight(#28), matching Bitcoin Core’s own field name instead of the misspellingget_raw_mempoolhad carried inbtclib_node/rpc/callbacks.py. A straight rename, with no one release answering to both keys:btclib_node/rpc/callbacks.py’scallbacksdict still has nogetrawtransactionorgetblockcountentry, which is what #21 is tracking, so btclib’sBitcoinCoreFetchercannot address this node’s JSON-RPC surface yet, and there is no known client of it to carry across a two-key transition.The spell checkers’ suppressions for the misspelling are gone,
[tool.codespell]’signore-words-listentry and[tool.typos.default.extend-words]’s entry alike, each with the comment that justified it: a spell checker ignoring a word no longer in the tree has nothing left to ignore.
getblockheader describes a block off the best chain, instead of failing¶
A block this node has indexed and did not follow is answered with its header (#87).
get_block_headerread the height asheader_index.index(block_hash), andheader_indexholds the best chain alone: for a block on a losing fork – indexed, and described byblock_index.header_dictperfectly well – that raisedValueError: list.index(x): x not in list, whichrpc/main.py’shandle_rpcanswers as-32603 Internal Error. Measured on a regtest index carrying three headers and a one-header fork off the genesis, onorigin/mainand here: the raise becomesheight: 1,confirmations: -1, the fork’s parent aspreviousblockhash, and nonextblockhash. A client can now tell a block the chain did not keep from a node that has broken,-32603being what this node owes a genuine fault.The shape is Bitcoin Core’s.
blockheaderToJSONandComputeNextBlockAndDepthin itssrc/rpc/blockchain.cppanswer a header off the active chain withconfirmations: -1in place of a depth and with nonextblockhash, and with the height the block has on its own fork. What Core counts that depth against is the chain of blocks it has validated, where this stays with the best header chain the function already used: #178 is that difference, which predates this entry and is not what #87 asked about.The height is
BlockInfo.index, which every indexed block carries, rather than a position in a chain a fork is not on. Nothing changes for a block on the best chain: the two are the same number there, which is whattests/functional/rpc/chain_test.pyasserts of a height read back over a real index built withadd_headers.previousblockhashlikewise comes from the header’s own parent, which for a block on the best chain is theheader_index[height - 1]it was read from before.A hash this node has never seen still raises, and so do a non-hex parameter and no parameter at all: #179 is those three, which want a way for a callback to name an error code and are the same mechanism #83 is waiting on.
pytest --help prints the usage message instead of a traceback¶
uv run pytest --helpexited 1 with aTypeErrorout oftests/conftest.pyand printed nothing at all (#154). The coverage floor’sasks_for_everythingreadconfig.option.file_or_diras a list, and on the--helppath it isNone, the parse having been abandoned rather than left unfinished:--helpis bound to pytest’sHelpAction, which raisesPrintHelpto skip the rest of argument parsing, andConfig.parsecatches it and returns before the positional is consumed, so it still holds argparse’sNonedefault whenhelpconfigcalls_do_configure()itself and the hook fires. Folding it to no paths is the fix, and is what the function already means by no path — the run is not selective, so the floor is left where it is, which is right for a run that collects nothing. It is--helpalone and not the class of run-nothing options: on anorigin/mainsnapshot--markersand--fixtureseach exited 0 and--co -q tests/helpers.pyexited 5, each reaching the hook with a list. No gate types--help, which is why nothing caught it.The guard is a regression test’s job, because the coverage floor cannot see it.
tests/unit/coverage_floor_test.pybuilds the option namespace by keyword, and now takesfile_or_dir=Noneas well as a sequence, soa_config(file_or_dir=None)asks exactly what--helpasks and asserts the floor is left alone rather than that nothing raised. With theor []taken back out that one test fails with theTypeErrorabove, and nothing else does.An
orshort-circuit is invisible to branch coverage, whatever the layout. coverage.py records a branch as an arc between two line numbers, and both outcomes of anorleave the same line, so no arc distinguishes them. Spreading the expression over several lines does not help:coverage.parser.PythonParser.arcs()gives that form the same single exit, from the line the statement starts on. Sobranch = trueandfail_under = 100would not have demanded this test: with it deleted,uv run pyteststays green andtests/conftest.pydrops out of the report as fully covered. A fix of this shape has to be tested deliberately rather than left for the floor to ask.
getpeerinfo brackets an IPv6 host the way Core does¶
A peer on IPv6 is reported as
[2001:db8::1]:8333(#147). Run against a snapshot of55c5512,get_peer_infoanswered2001:db8::1:8333for a peer at that address on that port, where the host and the port cannot be told apart:2001:db8::1:8333is itself an address, so a client splitting on the last colon reads one of the two wrong.addr,addrbindandaddrlocalwere each written that way, and each now carries the brackets.The rule is
CService::ToStringAddrPort’s, in Core’ssrc/netaddress.cpp, which writes"[" + ToStringAddr() + "]:" + port_strfor every network itsIsIPv4() || IsTor() || IsI2P() || IsInternal()does not name. Core’s owngetpeerinfoputs each of those fields through it:src/rpc/net.cppwritesaddrbindfrom it,addrfromm_addr_name, whichsrc/net.cppsets to it where no name was dialled, andaddrlocalfrom the stringCopyStatsbuilds with it. This node has no onion or i2p socket to read a peer from, so IPv4 is the whole of what its own answer leaves unbracketed.A v4-mapped host is unwrapped rather than bracketed, so a v4 peer reads
1.2.3.4:8333. That is Core’s answer too,SetLegacyIPv6filing a mapped address under NET_IPV4, and it is what aNetworkAddressneeds, holding every address in the sixteen octets of an IPv6 one. Whether a BIP155 record spelled that way should be kept at all, and what a peer reads it back as, is a different question, and #151 is it.btclib_node/p2p/address.py’sip_and_porttakes a host’s text and a port, which is what lets the fields share it:getpeernameandgetsocknameanswer with a tuple and have noNetworkAddressto offer. A host that is not an IP address is refused rather than shown with brackets guessed at,ipaddress.ip_addressbeing what refuses.
getblockheader’s confirmations, and the code a bad request is owed¶
A header this node has accepted and not downloaded was reported confirmed (#178).
get_block_headermeasuredconfirmationsagainstblock_index.header_index, the best header chain, which holds a hash as soon as its header is accepted;active_chainholds only what the node has validated and connected. On a snapshot oforigin/mainatf53d9cb, three regtest headers added and nothing downloaded answerheight: 3 confirmations: 1 downloaded: Falsefor the last of them.get_block_headernow counts againstactive_chain, and the same setup answersconfirmations: -1.The rule is
ComputeNextBlockAndDepth’s, in Core’ssrc/rpc/blockchain.cpp, which counts a depth fromchainman.ActiveChain().Tip()and answers-1for a block that chain does not hold at its own height.heightstaysBlockInfo.index, which every indexed header carries; only what it is compared against moved.A hash nothing indexed, a hash that is not hex, and no hash at all each raised out of the callback and were answered
-32603 Internal Error(#179), the code this node owes its own fault and not a client’s mistake. On the same snapshot: a hash nothing indexed raisesKeyError, a non-hex hash raisesValueError, no parameter raisesIndexError, and each reachesrpc/main.py’s catch-all.btclib_node/rpc/errors.py’sRpcErroris the mechanism #83 asked for: a callback names the code and the message its refusal is owed, andhandle_rpcanswers with them rather than logging the request as a fault of the node’s.get_block_headerraises it for the three cases above, with the codes Core gives the same three refusals:RPC_INVALID_ADDRESS_OR_KEY(-5) for a hashLookupBlockIndexdoes not have,RPC_INVALID_PARAMETER(-8) for oneParseHashVcannot read as hex, andRPC_MISC_ERROR(-1) for a call short of its required argument, which is whatRPCMethod::HandleRequestthrows andExecuteCommand’scatch (const std::exception&)turns into.#83’s other half,sendrawtransaction, does not raise it yet.error_msgtakes the id of the request it answers. A method not incallbacksused to answer"id": nullregardless of whatis_valid_rpchad already read from the request; JSON-RPC 2.0’s section 5 reservesnullfor a request whose id could not be read at all, not for oneis_valid_rpcalready confirmed carried one.
getblockheader’s two parameters, checked and read the way Core’s are¶
A
blockhashthat is not a string used to reachbytes.fromhexunguarded (#212).bytes.fromhex(5)raisesTypeError, which theexcept ValueErroraround that call did not catch, so it fell through torpc/main.py’s catch-all and answered-32603 Internal Error— the code this node owes its own fault, for a request that named the wrong JSON type.RPCMethod::HandleRequestchecks a declared argument’s type before the handler body runs at all (src/rpc/util.cpp:653-661), so Core never reachesParseHashVfor such a call either; it answersRPC_TYPE_ERROR(-3), whichbtclib_node/rpc/errors.py’sRpcErrorCodenow names andget_block_headerraises for ablockhashof any type butstr.get_block_headernever read a second parameter (#215). Core’sgetblockheadertakes an optionalverbose(RPCArg::Type::BOOL, RPCArg::Default{true},src/rpc/blockchain.cpp :617): true answers the JSON object this node already built, false answers the header’s own eighty bytes, hex-encoded (src/rpc/blockchain.cpp:668-673).get_block_headernow readsparams[1], defaults it toTruewhere omitted ornull, and answersheader.serialize().hex()for a false one — the same bytesBlockHeader.serialize()puts on the wire, hex-encoded rather than built into the object.verboseis type-checked the same wayblockhashis. Averboseof any JSON type butbool(ornull, which stands for the default) is the sameRPC_TYPE_ERRORblockhashis refused with, againstRPCArg::Type::BOOL.errors.py’sjson_type_namenames the six JSON types the way Core’s ownuvTypeNamedoes (src/univalue/lib/univalue.cpp), which is the vocabularyRPC_TYPE_ERROR’s message speaks and what both checks above report the wrong type with.
The issue template’s security link is the advisory form, not the policy¶
.github/ISSUE_TEMPLATE/config.yml’s Security vulnerability entry links/security/advisories/new, where it linked/security/policy(#136). Private vulnerability reporting is on for this repository, so the form the link now opens exists;REPOSITORY.mdrecords the setting and points here rather than repeating it. This repository still keeps noSECURITY.mdof its own: the route the setting opens reopens no question #167 already settled about which repositories carry one.
The review workflow reads more, and reviews more pull requests¶
claude-review.yml’s--allowedToolsnow carriesBash(git:*),Bash(gh issue view:*)andBash(gh api:*)(#153).gh pr diffwas the job’s only avenue before this: no base to check a “what this did before” claim against, and no way to read the issues a pull request says it closes.The review step now names
allowed_bots: "dependabot[bot]"(#168)..github/dependabot.ymlopens a pull request here every Thursday, and without this the action throwsWorkflow initiated by non-human actorfor that actor rather than reviewing it.The header no longer calls this the only workflow (#140).
lint.ymlandtest.ymlalready run on the same pull request and are required checks onmain; this one deliberately is not.
The merge gate no longer resolves live DNS to pass¶
The three
@pytest.mark.remote_datatests intests/unit/p2p/address_test.pythat calledPeerDB.get_addr_from_dnsagainstMain,TestNetandSigNet’s real bootstrap seeds are gone (#135).pyproject.toml’saddoptscarries--remote-data=any, which forced them into every coverage run test.yml gates a pull request on, so a DNS hiccup or a seed going away turned that gate red on a change that touched neither.get_addr_from_dns’s own logic — the union over several seeds, the dedup, thegaierrorskip, an IPv6 answer’s host and port — is still exercised, deterministically, by the stubbed-loop tests already in the same file..github/workflows/bootstrap-dns.ymlasks the three real chains’ seeds the same question the removed tests did, for the same reasonlinks.ymlis not a merge gate: a host having a bad afternoon is a thing to re-run, not a thing a pull request should have to fix. It runs weekly, on the row btclib-org/.github#201 gave it in section 10 of that repository’s README, and on demand.
A listener that cannot bind now says so, and a refused dial costs microseconds¶
A P2P or RPC listener whose bind fails now ends the manager’s thread with the
OSError, instead of leaving itis_alive()over a socket that never came up (#88). Both managers scheduledserverthroughrun_coroutine_threadsafe, whose returnedconcurrent.futures.Futurenobody read; a bind failure inside that coroutine sat in the unread future while the thread ran on._bindnow runs synchronously inrun, beforerun_forever, so the same failure raises out ofrunitself and is logged. Also closes the socket_bindhad already opened when the bind or the listen after it fails, which used to leak the file descriptor for the same reason the exception vanished.A refused dial no longer costs the second a ten-pass, 0.1s poll always charged it, refused or merely slow alike (#90).
btclib_node/p2p/address.py’sdialreads the kernel’s own answer throughloop.sock_connect, which watches the socket become writable and checksSO_ERRORthe moment it does, wrapped inasyncio.wait_forfor the one real timeout the poll’s two magic numbers stood in for.A dial that connects without ever raising
BlockingIOError— a local peer, most often — no longer leaks its socket (#148): the hand-rolledexcept BlockingIOError:arm that was the only place a successful dial got returned or a failed one got closed is gone along with the poll it guarded, so there is no longer an arm the immediate-success case can fail to reach.
getrawmempool’s two parameters, checked and read the way Core’s are¶
verbosewas read withparams[0] if params else Falseand never type-checked (#219), the same shape #212 named ongetblockheader’sblockhash.RPCMethod::HandleRequest(src/rpc/util.cpp:653-661) checks a declared argument’s type before the handler body runs;verboseandmempool_sequenceare both declaredRPCArg::Type::BOOL(src/rpc/mempool.cpp:694-695).btclib_node/rpc/errors.py’sbool_paramreads a declared bool argument the same wayget_block_header’s ownverbosecheck does, raisingRPC_TYPE_ERRORfor anything else;get_block_headernow calls it too, in place of the check it carried on its own.The default answer,
verboseandmempool_sequenceboth false, was{"txids": [...]}, not the plain arrayMempoolToJSONanswers with in that case (src/rpc/mempool.cpp:624-634).get_raw_mempoolnow answers a bare list of hex txids there, and reserves the object shape for wheremempool_sequenceis true.mempool_sequencewas never read at all (#219). It now attachesMempool’s own running count of the transactions it has added or removed, under the keymempool_sequence, next totxids(src/rpc/mempool.cpp:635-639).Mempoolgained asequencefield, starting at1and bumped once inadd_txand once inremove_tx, on the same branch that already guards each against a no-op — Core’s ownm_sequence_numberstarts at1, not0(src/txmempool.h:202), and is “incremented once every time a transaction is added or removed from the mempool for any reason” (:200-202), so a fresh mempool with no events answersmempool_sequence: 1, not0, and a duplicate add or an absent remove is neither an addition nor a removal.verboseandmempool_sequenceboth true is refused, matchingMempoolToJSON’s ownRPC_INVALID_PARAMETERfor the combination (src/rpc/mempool.cpp:608-611), rather than silently answering one and dropping the other.
getblockhash’s height, checked and bounded the way Core’s is¶
A negative height no longer reads the active chain from its own end (#234).
active_chain[int(params[0])]handed Python’s own list indexing a negative number, which counts from the end rather than raising, sogetblockhashwith-1silently answered the tip’s hash — a wrong answer, not an error. Core refusesnHeight < 0outright (src/rpc/blockchain.cpp:599-601), the sameRPC_INVALID_PARAMETER,"Block height out of range", a height past the tip already got.A height of the wrong JSON type, or none at all, used to reach
int()unguarded (#234), the same shape #212 and #219 named ongetblockheader’sblockhashandgetrawmempool’sverbose:int(None)raisesTypeError,int("x")raisesValueError, and an emptyparamsraisesIndexError, none of them caught, all three reaching-32603 Internal Error.heightis declaredRPCArg::Type::NUM(src/rpc/blockchain.cpp:585); a JSON value of any other type is nowRPC_TYPE_ERROR, the same checkRPCMethod::HandleRequestmakes before its own handler runs (src/rpc/util.cpp:653-661), and an omitted height isRPC_MISC_ERRORwith the method’s own usage —getblockhash height, unquoted, unlikegetblockheader’s own quoted"blockhash":RPCArg::ToString(oneline=true)quotes an argument’s name only forType::STR/STR_HEX, andheightisType::NUM(src/rpc/util.cpp:1265-1286).A height written as a JSON number with a decimal point is
RPC_MISC_ERROR, not silently truncated.int(1.5)truncates toward zero without complaint;UniValue::getInt<int>()fails on any such literal regardless of its value, and thestd::runtime_errorit throws isExecuteCommand’s genericcatch (const std::exception&)case, answeredRPC_MISC_ERRORand notRPC_TYPE_ERROR(src/rpc/server.cpp:884-886).
The review prompt is told the checkout it runs against is shallow¶
claude-review.yml’s prompt now names the checkout’sfetch-depth: 1and says what to do about it (#222).--allowedToolshas carriedBash(git:*)since #153, for checking a claim about what the tree looked like before a diff, but a depth-1 checkout of a pull request’s merge commit carries no parent history forgit logorgit diffto walk, and nothing told the model so. The prompt now points a single-file check atgh api repos/<repo>/contents/<path>?ref=<sha>, which answers regardless of the checkout’s depth, and a real range atgit fetch origin <base ref>first — the base ref now passed in the prompt header alongsideREPOandPR NUMBER— rather than raisingfetch-depthfor every run whether a review needs the history or not.
The ack-of-record comment cites the organization’s standard, not a local section¶
claude-review.yml’s ack-of-this-head comment now cites “section 11 of the organization’s standard” instead of “README.md’s section 11” (btclib-org/.github#243). This repository’s ownREADME.mdcarries no numbered sections, so the citation pointed a reader at a document this tree does not have; the wording now matches the workflow’s other copies, which name the standard rather than a file local to the reading repository.
DownloadManager’s per-peer bookkeeping stops working against itself¶
block_download’s assignment loop now excludes a connection markedpending_eviction(#68). The 120-second stall mark emptiedconn.download_queueto free those blocks for another peer, but nothing past it in the loop read the mark, so the loop’s owndownload_queue == []test read the just-emptied queue as “ready for more work” and handed the peer back the blocks it was already failing to deliver. The mark now means what it says until the peer’s next block clears it (callbacks.block) or the 300-second bound disconnects it.A transaction
DownloadManager.tx_downloadaccepts is queued on the connection it will be announced to,Connection.tx_announce_queue, and sent as anInvonly once that connection’s ownConnection.next_inv_send_timecomes due (#141), an exponential draw around a mean of 5 seconds for an inbound connection and 2 for an outbound one —INBOUND_INVENTORY_BROADCAST_INTERVAL,OUTBOUND_INVENTORY_BROADCAST_INTERVALandrand_exp_duration, net_processing.cpp on bitcoin/bitcoin@58a7869f86 — rather than the single per-stepInvthe previous batch-of-five removal (#114) turned into an immediate announcement. An outbound connection’s draw is its own; an inbound one’s is shared with every other inbound connection of the same address family (DownloadManager._next_inbound_inv_time,_inbound_net_class, mirroringNextInvToInboundsand theCNode::m_network_keyit is keyed on, net.h and net.cpp of the same commit — notNetGroupManager::GetGroup, which feedsnKeyedNetGroupfor addrman and eviction instead), so a peer opening several inbound connections to this node, from one address or from several, cannot average several independent draws down to a receipt time finer than one connection’s own jitter allows.P2pManager.broadcast_raw_transactionno longer pushes aTxof its own the instant it is called: it appends to the same list a relayed transaction’s own arrival does,conn_idNonein place of a peer to exclude, so a transaction of this node’s own goes through the same queue and the same delay — the gap between atxa peer sends this node and theinvthis node sends on no longer says whether this node originated it or relayed it.A
notfoundthis node receives for a transaction now clears that peer’s own outstanding ask,Connection.tx_requested(#144), the per-peer tabletx_download’s own request loop reads to avoid asking a peer twice for a wtxid it has not yet answered — mirroringTxDownloadManagerImpl::ReceivedNotFound(net_processing.cpp, the same commit), which reads only the transaction items of anotfoundfor the same reason. Before this, anotfoundwas logged and nothing else, since there was no per-peer record of an outstanding ask for it to clear.
Transaction relay is bounded, expires and only announces what it kept¶
_send_due_announcementsnow splitsConnection.tx_announce_queueintoInvmessages of at mostMAX_INV_SZentries each (closes #282), rather than building oneInvfrom the whole queue.btclib.p2p.inventory.Invraises past that bound on construction, and a slow-scheduled connection’s own timer — a mean of several seconds, an exponential draw’s own tail longer still — was enough wall-clock time for a busy node’s mempool churn to grow the queue past it, taking downNode.run’s own loop from insidetx_download. Core’s ownSendMessages(net_processing.cpp) answers the same way: severalMakeAndPushMessagecalls of at mostMAX_INV_SZeach.Connection.tx_requested’s entries now expire after 60 seconds (closes #289), Core’s ownGETDATA_TX_INTERVAL(node/txdownloadman.h). A peer that neither sent the transaction nor answerednotfoundleft its own entry in place forever, which madetx_download’swantedfilter read agetdataas still outstanding for the rest of that connection’s life — this node would never ask that one peer for the wtxid again, even past a later re-announcement.Mempool.add_txnow reports whether it added the transaction, andp2p/callbacks.py’stxhandler queues an announcement only when it did (closes #277). The handler used to gate bothadd_txand the announcement queue,DownloadManager.received_txs, on onecontains_txcheck taken before either ran;add_txsilently declines pastMempool.is_full(), so a transaction dropped for a full mempool was still announced to every other peer, which then asked for it and gotnotfound.
sendrawtransaction refuses a transaction a full mempool could not keep¶
send_raw_transactionnow raisesRpcErrorCode.VERIFY_REJECTED(“Mempool is full”) rather than answeringtx.id.hex()for a transactionMempool.add_txsilently declined pastMempool.is_full()(closes #293), the same defect #277 fixed on the peer-to-peer path.-26is Core’s own code for this refusal too:TxValidationResult::TX_MEMPOOL_POLICYinvalidated “mempool full” (validation.cpp) becomesTransactionError::MEMPOOL_REJECTED(node/transaction.cpp), whichRPCErrorFromTransactionError(rpc/util.cpp) answers withRPC_TRANSACTION_REJECTED— a bare alias ofRPC_VERIFY_REJECTED(rpc/protocol.h, bitcoin/bitcoin@58a7869f86). The exemption for a resubmission is keyed ontx.id in node.mempool.txid_index, notMempool.contains_tx, which is keyed by wtxid: a resubmission under a different witness is still tolerated and reannounced, mirroringBroadcastTransaction’s own early return for a txid already in the mempool – itself txid-keyed, and explicit that the held transaction “may have the same or different witness” – which does not reach Core’s own capacity check either. What is reannounced on a resubmission is the mempool’s own copy of the transaction and not the resubmitted object: the two can carry different witnesses and therefore different wtxids, andP2pManager.broadcast_raw_transactionqueues whichever one it is handed for announcement by that object’s own.hash– the same substitutionBroadcastTransactionitself makes (“Use the mempool’s wtxid for reannouncement”), needed here for the same reason: announcing a wtxidadd_txnever stored answers a peer’sgetdatawithnotfound. This substitution is not gated onMempool.is_full():add_txreturnsFalsefor an already-held txid whether or not the mempool is full, so a resubmission under a different witness into a mempool with room to spare reached the same mismatched-wtxid announcement before this change, unrelated to the refusal above and present before this branch touched the file.
Two testnet blocks exercise the filter index at a scale Core’s vectors miss¶
tests/unit/chainstate/_data/testnet_bip158_vectors.jsonadds heights 54499 and 54503, derived rather than vendored, besideblockfilters.json’s ten (closes #181). Neither is in Bitcoin Core’s ownsrc/test/data/blockfilters.json, whose largest block is a few kilobytes with no transaction spending another in the same block; 54499 is forty-odd kilobytes and twenty-four transactions, most of them resolving a previous output from elsewhere in the same block, which is the scenario none of Core’s rows reaches. The two blocks were pulled from testnet by the hash a survey of Libbitcoin’s test suite named, and their filters built with this tree’s ownBasicBlockFilter.from_block— nothing of Libbitcoin, AGPL-3.0-or-later, is in the tree; only the two block hashes and, as a positive control, the filter an independent SipHash-2-4 and Golomb-Rice implementation in that suite computed for height 54503. The filter header column has no external source either and is computed locally, chained only within the new file.tests/_data/README.mdhas the full derivation.