btclib_node package

Subpackages

Submodules

btclib_node.chains module

The networks this node can join, and the genesis block of each.

Chain and its four leaves – Main, TestNet, SigNet, RegTest – carry a network’s magic, its seed addresses, its script-flag activation heights and its own genesis block, built once by create_genesis below from the constants each leaf supplies. config.py’s _resolve_chain is what turns a chain’s name, read from the command line or a functional test, into one of these.

class btclib_node.chains.Chain(name: str, port: int, rpc_port: int, addresses: list[str], genesis_block: Block, flags: list[tuple[int, str]], pow_allow_min_difficulty_blocks: bool, pow_no_retargeting: bool, subsidy_halving_interval: int, bip34_height: int, bip30_exceptions: list[tuple[int, bytes]], minimum_chain_work: int, prune_after_height: int)[source]

Bases: object

A network this node can join: its magic, its seeds and its genesis.

Main, TestNet, SigNet and RegTest below are its four leaves, each hardcoding one network’s own constants in its __init__ rather than taking them as arguments, since there is exactly one of each and nothing else ever builds one.

property genesis: BlockHeader

Return the genesis header, which is what most callers want.

property magic: bytes

The network’s four-byte magic, the octets a message starts with.

property pow_limit_bits: bytes

The network’s easiest target, as its genesis block’s own bits.

subsidy(height: int) int[source]

Return the block reward at height: fifty bitcoin, halved by height.

Core’s GetBlockSubsidy (src/validation.cpp:1844, at bitcoin/bitcoin@204256c73f): fifty bitcoin, right-shifted once per subsidy_halving_interval blocks, forced to zero once that shift is undefined for a native int.

class btclib_node.chains.Main[source]

Bases: Chain

Mainnet: the chain real bitcoin moves on.

class btclib_node.chains.RegTest[source]

Bases: Chain

A local, disposable chain: no seeds, an easy target, no retargeting.

Every flag activates at height 0 and the difficulty never moves off the genesis’s own, so a functional test can mine past any of them in as many blocks as it needs, on demand.

class btclib_node.chains.SigNet[source]

Bases: Chain

The default public signet, not a custom one built from its own challenge.

Every flag activates at height 0, since signet is a fresh chain each time it is reset rather than one carrying mainnet’s own history.

class btclib_node.chains.TestNet[source]

Bases: Chain

Testnet3: the long-running public test chain.

btclib_node.cli module

main, the command line pip install btclib-node installs.

argparse, not click: pyproject.toml’s [project.scripts] entry below is the only new surface this adds, and this module is what it points at. click would be this wheel’s second runtime dependency – .github/scripts/generate_sbom.py names exactly one today, btclib itself – for what a flag list this size does not need: argparse costs nothing on the SBOM and reads a single-dash long option (parser.add_argument(“-datadir”)) the same way Core’s own ArgsManager does, which is why every flag below is spelled -datadir rather than –datadir, though the double-dash form is accepted too (Core normalises a leading to - before parsing a key, src/common/ args.cpp:221-222, at bitcoin/bitcoin@ca7162cde5, and add_argument below registers both spellings for the same reason). The decision and its SBOM consequence are btclib-org/btclib-node#583’s own to record; this sentence is this module’s copy of it, not a second decision.

Every flag below is one of Config’s own fields (config.py), named and defaulted the way SetupServerArgs (src/init.cpp, same sha) names and defaults its own – -datadir=<dir>, -blocksdir=<dir>, -conf=<file>, -chain=/-testnet/-signet/-regtest, -port=, -rpcport=, -rpcbind=, -prune=, -debug, -connect=, -addnode=, -listen=/-nolisten. Three Config fields have no flag here: min_relay_feerate (Core’s own -minrelaytxfee is BTC/kvB and this field is priced in sat/kvB already, config.py’s own comment on DEFAULT_MIN_RELAY_FEERATE argues why nothing enforces it yet; the unit translation is deferred rather than done half-heartedly), log_path (this command always takes Config’s own default – a file under the data directory – an operator who wants console output can read it from there, and scripts/chains/’s three deleted files are what used to make that choice for a caller who ran them directly instead), and allow_p2p/ allow_rpc (both listeners are always requested, matching every Config() call this module makes; there is no flag equivalent to allow_rpc=False here because Core has none either – the RPC server not starting is a consequence of a bind failure, not a flag).

-blocksdir=<dir> names the base BlockDB (block_db/__init__.py) writes its own files under, Core’s own “default: <datadir>” applying when it is not given – unlike -datadir, it is read from bitcoin.conf normally, since which file to read never depends on it the way it depends on -datadir. -blocksdir naming a directory that does not already exist is fatal, Config.__init__’s own refusal, matching Core’s “Specified blocks directory … does not exist” (src/init.cpp:1006, same sha) rather than creating one silently.

Reading Core’s own blk*.dat is not implemented, and is not started here either: the files are Core’s format and the validation order is this node’s own, and -connect/-addnode below already deliver the same blocks over loopback p2p with no new parser – Node.run’s own comment on dialling them is where that route is wired in. btclib-org/btclib-node#573 is the issue this records the decision against.

A run through this command stays one process past the start of block download for two reasons, one enforced regardless of the other. Node.__init__ refuses outright inside a re-imported __main__ (ReimportedMainProcessError, issue #589 – the guard ISS 579 asked for, now enforced by Node itself rather than by a module-body if __name__ == “__main__”: every future caller has to remember), and that backstop holds whichever entry point built the Node. The console script [project.scripts] installs also never reaches that backstop in the first place: its own generated shim carries the same if __name__ == “__main__”: guard pytest’s own .venv/bin/pytest has (ISS 583’s own body quotes it), and a multiprocessing pool worker spawned from it re-executes that shim under __mp_main__ (_fixup_main_from_path, multiprocessing/spawn.py), never taking the guard’s own branch. python -m btclib_node needs neither argument: __main__.py’s own module docstring is where the third, narrower mechanism that exempts it – multiprocessing.spawn’s special case for any module named *.__main__ – is read from the interpreter’s own source rather than assumed.

## bitcoin.conf

Read the way Core’s own ReadConfigFiles/ReadConfigStream (src/common/config.cpp, same sha) read it, the default path itself computed by ArgsManager::GetConfigFilePath (src/common/args.cpp:897, same sha): a key=value line per option, named without the leading - this module’s own flags carry; # starts a comment that runs to the end of the line; a blank line and a comment-only line are skipped; a [section] line switches which section the lines under it belong to, until the next one. Every chain has its own section, main included (ChainTypeToString, src/util/chaintype.cpp) – not only the three alternate chains – and the default, unlabelled section at the top of the file applies everywhere chain/testnet/signet/regtest themselves are never read from a chain’s own section, only from the default one and the command line, which is what lets a file decide the chain in the first place rather than needing the chain decided already to know which section answers that question.

Precedence is the command line over the file, always. Within the file, a value in the active chain’s own section beats one in the default section, and where a scalar option (-port, -rpcport, -rpcbind, -prune) is named more than once at one precedence level the last one in the file wins – Core reverses that for backward compatibility (GetSetting’s own “Weird behavior preserved” comment, src/common/settings.cpp:170-177, same sha); this reader does not replicate the reversal, there being no existing file this tree has to stay compatible with. -connect and -addnode are not scalars: every value from every source that applies is dialled, none of them replacing another, which is Core’s own GetArgs/GetSettingsList shape for a repeatable option (src/common/settings.cpp:210-246).

Not every option answers to the file the same way once the chain is not main: -port, -rpcport, -rpcbind, -connect and -addnode are each declared NETWORK_ONLY in Core (src/init.cpp’s own AddArg calls for each), so the default section’s own value for one of these five is ignored once running testnet, signet or regtest – only that chain’s own section and the command line still reach it. -prune and -debug are not network-only and are read from the default section on every chain. This reader keeps exactly that split; _NETWORK_ONLY_KEYS below is where it is written down.

includeconf=<file>, resolved relative to the data directory the way Core resolves it, is read only from the root file’s own default section: Core additionally honours one named inside the active chain’s own section, and one nested inside an included file is warned about and ignored rather than followed (ReadConfigFiles, same file, 161-224) – neither of those two narrower cases is replicated here, the common shape being one includeconf= naming a secrets file from the top of an otherwise ordinary bitcoin.conf. -includeconf is not a flag of this module’s own: Core accepts it on the command line only negated (-noincludeconf), and this module has no generic negation – -nolisten is the one negated spelling it registers, by hand, because Core’s own -connect interaction turns -listen off and an operator needs a way to say so – so -includeconf is simply not registered as a flag here at all, which refuses it exactly where the negated case would have covered no other command line spelling anyway. conf= inside a file is refused the way Core refuses it – fatally, “conf cannot be set in a configuration file” – and datadir= inside one is not read at all (unlike Core, which lets a file move the data directory read after the file naming it was found): this module needs a -datadir before it can know a file’s own default path, so a value the file might carry for it can never be the one that located that same file, and honouring it for anything read afterwards would make the same key mean two different things depending on when it is read. Warned about on stderr with its own message rather than the generic one below, since datadir is a real, documented flag and not a typo the generic message would have a reader believe it was.

An unrecognised key in the file is warned about, on stderr, and ignored – Core’s own default (ReadConfigFiles(error, /*ignore_invalid_keys=*/true), called this way from bitcoin.cpp, common/init.cpp and bitcoin-cli.cpp alike, same sha) rather than the fatal alternative that flag also allows. An unrecognised key on the command line is refused by argparse itself, the same way Core refuses one there too (“Invalid parameter %s”).

btclib_node.cli.build_config(argv: Sequence[__annotationlib_name_1__] | None = None) Config[source]

Parse argv (sys.argv[1:] if None) and its -conf into a Config.

Raises ValueError on a malformed argument, a malformed configuration file, or an unknown chain.

btclib_node.cli.main(argv: Sequence[__annotationlib_name_1__] | None = None) None[source]

Build a Config from the command line and bitcoin.conf, and run it.

Node is a non-daemon thread (__init__.py’s own module docstring): once node.start() returns, this function itself has nothing left to do, and the interpreter stays up on that thread alone until a signal install_signal_handlers below caught stops it – the same shape scripts/chains/’s three now-deleted files had, moved here.

btclib_node.config module

Config, the settings one Node is built from.

Which chain to join, where its data lives, which listeners to start and on which interfaces, and the feerate floor it tells a peer about in feefilterDEFAULT_MIN_RELAY_FEERATE below, Core’s own DEFAULT_MIN_RELAY_TX_FEE. _resolve_chain is what turns a chain already built, or a network’s name, into the Chain a Config carries. split_host_port is cli.py’s own splitter for -rpcbind’s optional port too, which is why it is public here rather than named with a leading underscore.

class btclib_node.config.Config(*, chain: Chain | str = Main(name='mainnet', port=8333, rpc_port=8332, addresses=['seed.bitcoin.sipa.be', 'dnsseed.bluematt.me', 'dnsseed.bitcoin.dashjr.org', 'seed.bitcoinstats.com', 'seed.bitcoin.jonasschnelli.ch', 'seed.btc.petertodd.org', 'seed.bitcoin.sprovoost.nl', 'dnsseed.emzy.de', 'seed.bitcoin.wiz.biz'], genesis_block=Block(header=BlockHeader(version=1, previous_block_hash=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', merkle_root=b'J^\x1eK\xaa\xb8\x9f:2Q\x8a\x88\xc3\x1b\xc8\x7fa\x8fvg>,\xc7z\xb2\x12{z\xfd\xed\xa3;', time=datetime.datetime(2009, 1, 3, 18, 15, 5, tzinfo=datetime.timezone.utc), bits=b'\x1d\x00\xff\xff', nonce=2083236893), transactions=[Tx(version=1, lock_time=0, vin=[TxIn(prev_out=OutPoint(tx_id=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', vout=4294967295), script_sig=b'\x04\xff\xff\x00\x1d\x01\x04EThe Times 03/Jan/2009 Chancellor on brink of second bailout for banks', sequence=4294967295, script_witness=Witness(stack=()))], vout=[TxOut(value=5000000000, script_pub_key=ScriptPubKey(script=b"A\x04g\x8a\xfd\xb0\xfeUH'\x19g\xf1\xa6q0\xb7\x10\\\xd6\xa8(\xe09\t\xa6yb\xe0\xea\x1fa\xde\xb6I\xf6\xbc?L\xef8\xc4\xf3U\x04\xe5\x1e\xc1\x12\xde\\8M\xf7\xba\x0b\x8dW\x8aLp+k\xf1\x1d_\xac", network='mainnet'))])]), flags=[(170061, 'P2SH'), (363725, 'DERSIG'), (388381, 'CHECKLOCKTIMEVERIFY'), (419328, 'CHECKSEQUENCEVERIFY'), (481824, 'WITNESS'), (481824, 'NULLDUMMY'), (709632, 'TAPROOT')], pow_allow_min_difficulty_blocks=False, pow_no_retargeting=False, subsidy_halving_interval=210000, bip34_height=227931, bip30_exceptions=[(91842, b'\x00\x00\x00\x00\x00\nM\n9\x81a\xff\xc1c\xc5\x03v;\x1fC`c\x93\x93\xe0\xe4\xc8\xe3\x00\xe0\xca\xec'), (91880, b'\x00\x00\x00\x00\x00\x07C\xf1\x90\xa1\x8cUw\xa3\xc2\xd2\xa1\xf6\x10\xae\x96\x01\xac\x04j8\x08L\xcb|\xd7!')], minimum_chain_work=84962480044215842430416822320, prune_after_height=100000), data_dir: str | Path | None = None, blocks_dir: str | Path | None = None, p2p_port: int | None = None, rpc_port: int | None = None, rpc_host: str = '127.0.0.1', allow_p2p: bool = True, allow_rpc: bool = True, pruned: bool = False, prune_target_mib: int | None = None, debug: bool = False, log_path: str | None = 'history.log', min_relay_feerate: FeeRate = FeeRate(sats_per_kvbyte=100), connect: Sequence[__annotationlib_name_1__] = (), addnode: Sequence[__annotationlib_name_2__] = (), listen: bool = True)[source]

Bases: object

Every setting one Node is built from, flat and keyword-only.

Built by __init__ below rather than by the fields’ own defaults, since a chain given as a name has to resolve to a Chain first, and a port left unset by allow_p2p=False/allow_rpc=False has to become None rather than the class’s own declared int.

btclib_node.config.split_host_port(spec: str, default_port: int) tuple[str, int][source]

Split “host[:port]” the way Core’s own SplitHostPort does.

The last colon is the port separator, unless it is not the only one and does not close an IPv6 literal’s own […] – an IPv6 address given without brackets and without a port is read whole rather than split on one of its own colons, exactly what src/util/strencodings.cpp’s SplitHostPort does (read at bitcoin/bitcoin@ca7162cde5). default_port is what a spec naming none falls back to: Core’s own callers pre-fill the port before calling SplitHostPort, which only overwrites it when the spec actually names one (ConnectNode, src/net.cpp:505-507, same sha) – -connect=1.2.3.4 and -addnode=1.2.3.4 both dial the chain’s own default P2P port this way.

btclib_node.constants module

The small enumerations and constants shared across this package.

ProtocolVersion, P2pConnStatus for a single peer connection’s own handshake state, NodeStatus for what stage of startup, sync or shutdown the node as a whole is in, COINBASE_MATURITY, and MAX_TIP_AGE.

class btclib_node.constants.NodeStatus(*values)[source]

Bases: IntEnum

Which stage of startup or sync a Node as a whole is in.

class btclib_node.constants.P2pConnStatus(*values)[source]

Bases: IntEnum

One peer connection’s own handshake state, from accept to verack.

btclib_node.db module

The ordered key-value store every index of this node is kept in.

Read a key, write one, delete one, write several as one, walk the whole store in key order, and close: that is everything any index here asks of it. They are behind one class so that what implements them is one decision in one file – src/btclib_node/db.py – rather than a library named in as many modules as import it. None of that surface moved with this file’s own implementation, below.

The implementation is RocksDB, through rocksdict, and the reason is not speed, even though it is markedly faster (btclib-org/btclib-node#641’s own measurement: load, flush and get all several times sqlite3’s own, on this tree’s own workload). It is integrity. btclib-org/btclib-node#107 chose sqlite3 over LevelDB – Bitcoin Core’s own store, src/leveldb/, vendored into its tree and wrapped by CDBWrapper – to avoid a compiled dependency: no wheel missing on a platform, no compiler, no stubs. That measurement never weighed what LevelDB gives Core in exchange: CDBWrapper reads with verify_checksums = true (src/dbwrapper.cpp:248, at bitcoin/bitcoin@ca7162cde5) over data LevelDB itself checksums per block (CRC32C), so a bit flipped on disk is an exception at the read, never a wrong answer. sqlite3, as this store used it, checksummed nothing: a flipped bit in a value read back as a different, valid-looking value, silently, and a flipped bit in a key made the record it named cease to exist, both measured directly (btclib-org/btclib-node#641). Only the first of those two is even in reach of a scheme built by hand inside a value’s own bytes (btclib-org/btclib-node#637’s own per-record CRC): a corrupted key is never read under its own name, so nothing carried inside the record it pointed to can ever see it, and that is the fault that silently forks a node off the network, a UTXO its own consensus rules still consider spendable simply gone. rocksdict was chosen over plyvel – the library #107 measured LevelDB itself through – because plyvel is dead against this tree’s requires-python = “>=3.14”: no cp314 wheel at all, one platform, last released 2024-01 (btclib-org/btclib-node#641). rocksdict ships cp314 wheels on nine platforms (win_arm64 alone building from source), py.typed plus a .pyi, current releases – #107’s own packaging argument, answered on today’s numbers rather than assumed to still hold.

What is accepted in exchange, stated whole rather than left implicit: a compiled dependency returns, narrowing “wherever Python runs” to “wherever a wheel exists”; RSS several times sqlite3’s own (memtables and a block cache, sized below the way Core’s own -dbcache sizes the same knobs, and tunable the same way); rocksdict’s own exceptions arrive untyped, a bare Exception string-matched into this tree’s own StoreCorruptionError (exceptions.py); a reader in another process, which sqlite3’s WAL let in beside the writer, is refused outright by RocksDB’s own directory LOCK – nothing in this tree opens the store from a second process, so what is given up is a capability and not a caller; and one datadir migration, the same shape #107 itself already cost once. None of that is a departure from Following Bitcoin Core – it is Core’s own choice, reached this time by measurement rather than assumed, and the packaging half of it is the “Python-native” axis CLAUDE.md already carves out for this file, argued in the same terms db.py used against LevelDB in the first place. What was never available before now is: a wheel that carries LevelDB’s own fork, typed, on the platforms this tree ships to.

Key order is load-bearing, exactly as before: BlockIndex.init_from_db reads until the first key that is not a blkinfo-, so a prefix that sorted before that one would stop it early; BlockDB.init_from_db walks the whole store and dispatches on the prefix, which is slower and cannot be tripped that way. RocksDB keeps a key’s own bytes in lexicographic order the same way LevelDB does – the comparator neither side changes – so nothing here depends on this store’s own choice of engine.

`raw_mode=True`, on every `Options` and `WriteBatch` this file builds. rocksdict’s own default mode pickles a value that is not one of a handful of native types it recognizes, and tags even the ones it does with a type byte – an encoding this store never asked for and would pay for silently: every key and value here is already the caller’s own serialized bytes, and a comparison of rocksdict against sqlite3 that left the default mode on measured that tag-and-pickle overhead alongside the two stores rather than between them (btclib-org/btclib-node#641’s own comment names this the trap the benchmark could have fallen into).

## The configuration, matched to Core’s GetOptions/DBParams line by line

src/dbwrapper.cpp and src/dbwrapper.h, read at bitcoin/bitcoin@ca7162cde5:

  • options.compression = leveldb::kNoCompression (src/dbwrapper.cpp:145) -> DBCompressionType.none(). Compression is not network-visible, so it could in principle be a local choice, but the rule this file is written under is match unless something forces otherwise, and nothing does – it is also what keeps a corrupted record’s own bytes findable on disk by search rather than only by decompressing every block, which is what the corruption test below relies on.

  • options.paranoid_checks = true (src/dbwrapper.cpp:150,162) -> Options.set_paranoid_checks(True).

  • readoptions.verify_checksums = true and iteroptions.verify_checksums = true (src/dbwrapper.cpp:248-249) -> ReadOptions.set_verify_checksums(True), on the options this file passes to every get and to the scan __iter__ builds. rocksdict’s own ReadOptions default is already True; it is set here anyway, the way Core’s own constructor sets it explicitly rather than relying on LevelDB’s default, so a reader of this file sees the decision rather than has to already know the vendor’s default to trust it. iteroptions.fill_cache = false (src/dbwrapper.cpp:250) is matched too, on the scan alone: a full walk of the store is not the working set this store’s own block cache exists to hold hot, and Core does not let one displace it either.

  • DBParams.bloom_filter = true by default (src/dbwrapper.h:54) -> NewBloomFilterPolicy(10) (src/dbwrapper.cpp:144) -> BlockBasedOptions.set_bloom_filter(10, False), reached through Options.set_block_based_table_factory. This is the storage engine’s own bloom filter, over the bytes of every key this store holds, built and consulted entirely inside RocksDB – unrelated to BIP37’s own, deprecated peer-relay bloom filter, which is a network message this node’s own peers exchange and this store never sees.

  • block_cache = NewLRUCache(nCacheSize / 2) and write_buffer_size = nCacheSize / 4 (src/dbwrapper.cpp:142-143), nCacheSize being -dbcache, which this tree has no flag for. nCacheSize here is instead what Core’s own default -dbcache gives the coins database specifically, computed the way Core computes it and not guessed: node::GetDefaultDBCache (src/node/caches.cpp, same sha) answers 1_GiB on a 64-bit build with at least 4_GiB of RAM, DEFAULT_KERNEL_CACHE (450_MiB) otherwise; kernel::CacheSizes (src/kernel/caches.h, same sha) then takes block_tree_db = min(total / 8, 2_MiB) off the top and gives the coins database coins_db = min(remainder / 2, 8_MiB) – and remainder / 2 clears 8_MiB under either default (roughly 510_MiB and 224_MiB), so the coins database’s own share is the cap, MAX_COINS_DB_CACHE, 8_MiB, regardless of which default this tree would have read with no -dbcache of its own. _N_CACHE_SIZE below is that 8_MiB, and the block cache and write buffer this file builds from it are the same fractions of it Core takes of nCacheSize.

  • raw_mode=True is argued above, on both Options and WriteBatch.

  • WriteOptions’ own default, sync=False, is the counterpart of the synchronous=NORMAL the sqlite3 store ran under: a write reaches the operating system’s own page cache and is not fsynced before this store answers its caller, so a kill loses whatever was not yet flushed to disk and never corrupts what was. It is passed explicitly below, unchanged from its own default, for the same reason the read options above are passed rather than left to a default nothing here states.

## The datadir marker inverts

_SQLITE_MARKER refuses the file this store itself used to write, index.sqlite, the same way _LEVELDB_MARKER = “CURRENT” used to refuse the file LevelDB wrote before that – RocksDB being LevelDB’s own fork, it writes CURRENT too, so a marker built the old way would now refuse this store’s own datadir on its second open. Refusing index.sqlite by name is the same shape of guard for the same reason: a datadir written by the version before this one is not a store this version can silently start an empty chain over the top of, and the message stays “delete the directory and sync again” – there is nothing here to migrate a sqlite3 file’s own rows into RocksDB’s own SST format, and no attempt is made to.

## _SCHEMA_VERSION moves to its own column family

The old argument for keeping a schema version out of kv’s own key order – PRAGMA user_version, four bytes in the SQLite file’s own header, untouched by anything this class wrote into the table – carries over whole, and RocksDB’s own native answer to the same constraint is a second column family: _META_COLUMN_FAMILY, kept open beside the default one every get/put/delete/__iter__ above reads and writes, and never walked by any of them – BlockIndex .init_from_db stopping at the first key that is not its own blkinfo- never sees it, the same guarantee PRAGMA user_version gave by sitting outside kv entirely. _VERSION_KEY inside that column family holds _SCHEMA_VERSION, four bytes, big-endian, the same width PRAGMA user_version answered in.

The two states PRAGMA user_version == 0 used to leave ambiguous – a brand-new file, answering 0 because SQLite starts every one there, and a store written before this class carried a version at all – have a narrower RocksDB shape. A fresh store is a directory RocksDB has never opened: Options.create_missing_column_families(True) (below) creates _META_COLUMN_FAMILY itself, empty, on that first open, so the version key is absent there for the same reason it is absent from a brand-new SQLite file – nothing has written it yet – and kv (the default column family) is checked for a row exactly as before, to tell that state apart from the other one still possible: a version key absent from a column family this store’s own default policy would otherwise have already created empty, with data already sitting in kv. A store written by any version of this class, in contrast, cannot be version-less at all: _check_schema_version stamps _SCHEMA_VERSION into the meta column family before __init__ ever hands the object back to a caller, on every store this class has ever opened, so the “pre-versioning store” PRAGMA user_version had to distinguish by content has no RocksDB analogue to keep – it is simulated in the tests below, as it already was for SQLite, since nothing in this tree still writes one on purpose.

_VERSION_KEY is no longer the only thing this class keeps outside kv’s own key order: get_meta/put_meta below are the general form, and UtxoIndex’s own running MuHash commitment (btclib-org/btclib-node#639, chainstate/muhash.py) is what they were added for – a single scalar value, not a range any reader ever walks, which is exactly what a column family outside the default one’s own scan is for. put_meta stages into the same WriteBatch put/delete do when a batch is open, so a caller’s meta write and its coin writes commit or fail together – UtxoIndex.finalize is where that matters, writing the commitment and the coins it commits to in the one batch Chainstate.flush opens.

## Corruption is an error this tree now classifies itself

rocksdict raises a bare Exception on a checksum mismatch, its message beginning Corruption: – only DbClosedError is typed, per rocksdict’s own .pyi. get, __iter__ and the open itself (__init__, including the schema-version check it runs before handing the object back) each catch it there and raise this tree’s own StoreCorruptionError instead, string-matched rather than typed for the same reason rocksdict itself gives it no type. exceptions.py’s own docstring for that class argues why it is not ChainstateInconsistencyError: this store is PeerDB’s own as well as every chainstate index’s, and a corrupted address book is not a chainstate concern.

A point lookup and a scan report corruption differently, and only one of them raises on its own. Rdict.get – what get and the schema-version check above both call – raises the moment it meets a corrupted block, measured directly. Rdict.items and Rdict.keys do not: RocksDB’s own iterator, underneath either, answers a corrupted block the way it answers the genuine end of the store – Valid() turns false, and the fault is only ever in a separate status() call, which neither wrapper makes. Measured directly, twice: over a store with one corrupted block partway through, items() returned every pair before that block and then stopped, silently, and keys() on a store whose only intact block was the corrupted one returned nothing at all, indistinguishable from a genuinely empty store. The second is not a theoretical risk – _has_any_data is exactly the read _check_schema_version trusts to tell an empty store from one this class predates, so trusting keys() there would have let a corrupted, version-less store be silently stamped current rather than refused. __iter__ and _has_any_data below both go through the lower-level Rdict.iter instead – seek_to_first, walk valid(), and call status() once after – which is what makes them raise the same way get already does.

## The lock stays, for a different reason than before

RocksDB is internally thread-safe – unlike SQLite’s connection under check_same_thread=False, nothing here needs the RLock to serialize an ordinary get/put/delete across threads. Two measurements taken directly against this file’s own shape are why it stays regardless. First: a live iterator still holds the directory LOCK after close() returns – Rdict.close() on the handle this file calls self._db succeeds and returns, but a second Rdict opened on the same path still refuses with the OS-level lock held, for as long as anything – an iterator, a second column-family handle obtained through get_column_family – keeps the underlying handle alive from Python’s own side. __iter__ below reads the whole store into a list under the lock and drops the iterator before returning, exactly as it already did for SQLite’s own, different reason (a cursor stepped on by a second statement on the same connection); close closes both the default and the meta column-family handles this file keeps open, for the same reason. Second: close() racing an unguarded get() from another thread does not corrupt anything or crash the process the way CPython’s own sqlite3 used to – but it is not silent either, measured directly, three runs, same result each time: RuntimeError: Already mutably borrowed, rocksdict’s own Rust layer refusing the race rather than letting it through undefined. The RLock below is what keeps a caller from ever seeing that message: every read and every close take it, so a close() from one thread simply waits for a get() already in progress on another, the same shape test_close_waits_for_whoever _is_using_the_connection already pins.

A closed store’s `LOCK` releases synchronously, traced source to source and then measured directly on `windows-latest`. close()’s own drop chain – DbReferenceHolder::close (src/db_reference.rs, rocksdict at rocksdict/RocksDict@v0.3.29) dropping the last Arc<DB> clone into DBCommon::drop, ffi::rocksdb_close, DBImpl::~DBImpl, DBImpl::CloseHelper, which waits out every background compaction and flush before it ever reaches env_->UnlockFile (db/db_impl/db_impl.cc, RocksDB at facebook/rocksdb@44e95d8af5d7ec503b3f1d5754c3379ab6c29a9d, the sha rust-rocksdb’s own fork pins for that tag, Congyuwang/rust-rocksdb@4cf3c68a993b807bc54ff1c5293cdf49e62aaf72) – is synchronous on the calling thread the whole way, WinFileSystem::UnlockFile (port/win/env_win.cc) itself a bare delete lock into WinFileLock::~WinFileLock’s own ::CloseHandle (port/win/io_win.cc), nothing in it deferred to a background thread or to Python’s own garbage collector. A windows-latest run once found a LOCK still held immediately after three such closes and a shutil.copytree right behind them (btclib-org/btclib-node#683, run 33273020014); a single close()-then-reopen on the same path, dispatched separately to answer that finding on its own (btclib-org/btclib-node#703, run 33274969391), opened on its first attempt, and every close-then-reopen test already in this suite – this file’s own, block_db_test.py, chainstate/filter_index_test.py, chainstate/block_index_test.py, main_test.py’s first/reopened pair among them – passed in that same run. Weighed against the traced chain above, the first run’s own gap reads as contention on a loaded parallel runner rather than a defect this store, rocksdict, or RocksDB owes a fix for, and #703 closes on that measurement rather than on a code change.

A crash before `Chainstate.flush` writes costs whatever is staged since the last one, and never a torn store. main._finalize_fork no longer writes BlockIndex’s and FilterIndex’s own changes on every connected block: BlockIndex.stage_status and FilterIndex’s own pending (filter_index.py’s docstring) hold them the way UtxoIndex already held its own spends and creations, and Chainstate.flush writes all three into the one write_batch this store already gives a caller – one RocksDB WriteBatch, committed whole or not at all (write_batch’s own docstring below) – once UtxoIndex.should_flush says the staged UTXO cache has reached its own bound (utxo_index.py’s _FLUSH_BOUND), or once Chainstate.close is called. This is what btclib-org/btclib-node#586 measured: 3.53 billion inputs, one db.get and one db.delete apiece, and holding several blocks’ own changes staged rather than committing each block’s alone is the only lever on that, blocks connecting one at a time on this store’s own single writer. Crash atomicity itself – the property this whole recovery design rests on – was measured directly against both stores rather than assumed to carry over: a child process writing two-key batches forever, kill -9 at a random moment, reopen, check every pair whole, six runs each, 0 torn batches of roughly 570 000 committed on RocksDB against roughly 165 000 on sqlite3 in the same wall time (btclib-org/btclib-node#641).

So a block whose own status change never reached this store is, after an unclean stop, still whatever init_from_db last read for it – valid_header rather than in_active_chain – with nothing else on disk recording that it was ever tried. generate_active_chain and generate_block_candidates (chainstate/block_index.py) then rebuild active_chain and block_candidates without it, exactly as they would for a block this store has never seen; get_first_candidate offers it again, and update_chain (main.py) revalidates it in full, check_transactions included, and re-stages the identical Coin`s and the identical filter, both being pure functions of the block and its ancestry. Nothing is corrupted by this – what the last flush wrote is self-consistent, being one transaction, and everything after it is redone rather than read back. A clean stop costs none of it: `Chainstate.close calls flush first, so the cost above is what a kill or a crash that never reaches close leaves for the next start-up, bounded by _FLUSH_BOUND and paid in Node.worker_pool’s own parallel validation rather than in this store’s single-threaded writes – the resource #586 is about spending less of.

Bitcoin Core pays a narrower version of the same cost differently, because its own equivalent of these three indexes is not one store. Its block index (BlockTreeDB) and its coins cache (CCoinsViewDB) are two separate LevelDB instances, and Chainstate::FlushStateToDisk (validation.cpp, read at bitcoin/bitcoin@ca7162cde5) writes them in sequence rather than together – WriteBlockIndexDB() first, CoinsTip().Flush()/.Sync() second, both gated by the same should_write – so blocks connected since the last such flush are lost to a crash exactly the way this store loses them, and Core redoes them the same way, through the ordinary ActivateBestChain path rather than through anything below. What is narrower is the coins write itself: CCoinsViewDB::BatchWrite (txdb.cpp) splits a large flush into several separately-committed LevelDB batches capped by -dbbatchsize, so a crash during that one flush – after the block index’s own write already landed, mid-way through the coins side of it – leaves the two out of step by less than a whole flush interval. DB_HEAD_BLOCKS, a marker BatchWrite writes before its first batch and erases after its last, is what records that transition is in progress; Chainstate::ReplayBlocks reads it at start-up and, finding it set, re-applies (RollforwardBlock) every block between the old coins tip and the new one directly onto the coins cache, no script re-verification, because the block index – already durable when this coins write began – had already recorded them as validated.

This store never needs that: write_batch is one RocksDB WriteBatch regardless of how many keys it touches, so there is no sequence of several separately-committed writes inside one flush for a crash to land between, and so no marker to record one in progress. Bounding by entries rather than by write-batch bytes (utxo_index.py argues that choice) is part of the same shape – one flush, one commit, the whole of it or none.

class btclib_node.db.KeyValueStore(path: str | Path)[source]

Bases: object

An ordered store of octets by octets, in one directory.

One Rdict handle on the default column family, one more on the _META_COLUMN_FAMILY beside it, and a lock around every use of either. RocksDB is internally thread-safe on its own – the module docstring’s own “The lock stays, for a different reason than before” is where the two measurements that keep the RLock here anyway are argued.

Reentrant, because a batch holds the lock for its whole block and the writes inside it come back through the same door.

close() None[source]

Close both column-family handles, once, and refuse later use.

property closed: bool

Whether close has already been called.

delete(key: bytes) None[source]

Remove a key, whether or not it was there.

get(key: bytes) bytes | None[source]

Return the value stored under a key, or None.

get_meta(key: bytes) bytes | None[source]

Return the value stored under key in the meta column family.

The module docstring’s own “_SCHEMA_VERSION moves to its own column family” is where that family, and what belongs outside the default one’s own key order, is argued; UtxoIndex’s own running MuHash commitment (chainstate/muhash.py) is the second thing to live here, _VERSION_KEY being the first.

put(key: bytes, value: bytes) None[source]

Store a value under a key, replacing what was there.

put_meta(key: bytes, value: bytes) None[source]

Store value under key in the meta column family.

Inside a write_batch, staged in the same WriteBatch put/ delete above stage into – rocksdict’s own WriteBatch.put takes a column_family argument for exactly this, so a meta write and a default-column-family write commit together in one Rdict.write call rather than needing a second batch or a second lock. get_meta above needs no batch-aware counterpart: nothing here reads a meta key it may have just staged but not yet committed, unlike get, which write_batch’s own docstring does not claim either – a pending_batch is never read back through get/get_meta, only replayed onto the store on exit.

The ColumnFamily handle WriteBatch.put wants is fetched here and held in no attribute of this class: self._meta’s own kind of handle (Rdict, from get_column_family) is released by its own .close(), called from close() below, but get_column_family_handle’s own return type exposes none – measured directly, caching one on self the way self._meta is left a live Rdict reference open on this store’s directory LOCK past close(), with nothing in this class able to drop it, so a caller still holding this object – as every one here does, close() never being the last statement that touches it – found a second store at the same path refused. A name that lives only for the one call this method makes is dropped with it, on the same reasoning __iter__ above already reads the whole store into a list rather than handing out a live iterator.

write_batch() Iterator[__annotationlib_name_1__][source]

Write everything in the block, or nothing at all.

The lock is held for the whole batch, so nothing else reaches either column-family handle mid-batch; the writes inside re-enter it through put/delete above, which is what makes it an RLock.

Every write inside the block goes into one rocksdict WriteBatch, held here rather than written through as it arrives, and reaches the store in one call to Rdict.write only once the block exits without raising – an exception unwinding it instead leaves self._pending_batch cleared and nothing written, put/delete never having reached self._db at all. There is no RocksDB counterpart to BEGIN IMMEDIATE’s own timing (SQLite’s write lock taken when a batch opens rather than at its first write, so a second writer in another process waited from the start): RocksDB’s own directory LOCK is already held exclusively from the moment this store’s own __init__ opened it, well before any batch, so there is no window for a second writer to find open here that opening this store at all has not already closed.

A batch does not nest. One slot holds the pending batch, so an inner write_batch would commit its own writes on its own exit, outside the outer block’s atomicity and with nothing to say so; it raises instead, the way SQLite’s own nested BEGIN did.

btclib_node.download module

DownloadManager, what decides what this node asks its peers for.

Block download candidates and stall detection, transaction announcement and request tracking, and the trickle timing behind both – feefilter resends, address relay, and the exponential delays that keep two peers from being told the same thing in lockstep. Most of the constants here are a named Bitcoin Core constant carried over with the commit it was read at beside it, per this tree’s own convention of matching Core’s behaviour, always.

class btclib_node.download.DownloadManager(node: Node, logger: Logger)[source]

Bases: object

What decides what this node asks its peers for, one step at a time.

Block download candidates and stall detection, transaction announcement and request tracking, and the feefilter trickle: the module docstring above is where the constants each of those follows are argued against Core’s own.

block_download() None[source]

Refresh the block window, evict stalled peers, and request new work.

A no-op before headers are synced – there is nothing to request candidates against yet – and stall eviction only runs during IBD, once the chain is synced a slow peer costing this node latency rather than a stalled sync.

step() None[source]

Run one pass: block download, tx download, then feefilter resends.

tx_download() None[source]

Announce what this node received, and request what it still wants.

A no-op until the chain itself is synced: a peer’s inv for a transaction is only worth requesting once this node has a mempool to check it against, and until then everything received here is a block’s own, not a loose transaction.

btclib_node.exceptions module

Exception classes.

TRY002/TRY003 (issue #284) ask two things of a raise: that its class not be the bare Exception/BaseException every except Exception also catches, and that a message built ad hoc at the call site move into the class that carries it, so the same failure reads the same way everywhere it is raised. What is below groups by what actually went wrong rather than by which module raises it, since two call sites in different files can be the same failure – db.py’s two “the store is closed” sites are one class for exactly that reason.

Grouped this way rather than one class per call site: most of these raise once, are never caught by type (they propagate out of update_chain or a p2p/RPC handler and end the node or the one connection, per this file’s own docstring elsewhere in this tree), and a class per site would be a name invented for a message read exactly once. What groups here shares an actual class of failure instead – ChainstateInconsistencyError is every site downstream of update_chain finding that its own index promised something the data underneath it does not have, whichever index asked – except where the data in question is a candidate block’s own, not yet validated at all: InvalidBlockInputError is that one, UtxoIndex.add_block’s own two sites, which fire on a peer’s bad block rather than this tree’s own bug.

exception btclib_node.exceptions.ChainstateInconsistencyError(message: str)[source]

Bases: RuntimeError

The node’s own index promised something its data does not have.

Raised only where an earlier check already established the invariant this violates – a block marked downloaded that block_db does not hold, a reverse patch set_status already trusts that is not on disk, a UTXO apply_rev_block is asked to remove that this node’s own earlier add_block did not just add. apply_rev_block only ever inverts a reverse patch this node wrote for a block it already validated and connected, so a failure there is this tree’s own bookkeeping disagreeing with itself, the same test that separates this class from InvalidBlockInputError below, which shares two of its messages (“prevout not found”, “prevout already spent in this batch”) but not the invariant. A peer’s bad data, or a submitted transaction’s own content, is refused earlier and differently (BTClibException, a None handled in place, InvalidBlockInputError, or MissingPrevoutError); reaching here is never that.

Two call sites used to belong on the list above: a stored utxo- record UtxoIndex.add_block’s own prevout resolution or UtxoIndex.get_coin reads back that Coin.parse cannot read. Neither raises this class any more (btclib-org/btclib-node#650), and the reasoning is Core’s own. CDBWrapper::Read (src/dbwrapper.h:220-237, at bitcoin/bitcoin@ca7162cde5) catches a deserialize failure inside its own try and returns false, and CCoinsViewDB::GetCoin (src/txdb.cpp:88-95) turns that false into std::nullopt – the coin reads back as absent, never as an error. CDBWrapper reads with verify_checksums = true (src/dbwrapper.cpp:248), and a checksum mismatch reaches HandleError (src/dbwrapper.cpp:46-53), which throws dbwrapper_error and ends the process, before ReadImpl (src/dbwrapper.cpp:346-357) ever returns and Read’s own try runs at all – so the deserialize failure that try actually catches is a format mismatch on an intact, checksummed read, never bit rot. db.py’s own store now carries the same separation: RocksDB verifies an equivalent per-block checksum on every read (btclib-org/btclib-node#641), so a genuinely corrupted utxo- record is caught there, as StoreCorruptionError, before either Coin.parse call in UtxoIndex ever runs on it – and what reaches Coin.parse is therefore exactly the case Core answers absent to. UtxoIndex.get_coin now returns None for it, and UtxoIndex.add_block folds it into the same InvalidBlockInputError (“prevout not found”) a genuinely missing prevout already raises, matching GetCoin’s own std::nullopt rather than raising a class of this tree’s own that Core has no equivalent of at that call.

What that answer costs is real, and Core pays it too: 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 from here indistinguishable from a coin that never existed, and a later, genuinely valid block spending it is rejected. Consensus::CheckTxInputs’s own HaveInputs check (src/consensus/tx_verify.cpp:169-174, at bitcoin/bitcoin@ca7162cde5) answers a missing coin TX_MISSING_INPUTS, “bad-txns-inputs-missingorspent”, and ConnectBlock (src/validation.cpp:2543-2547) turns that failure into BlockValidationResult::BLOCK_CONSENSUS – an ordinary consensus rejection, on the wire indistinguishable from a block that spent a coin that genuinely never existed. Core carries this exposure knowingly, not by oversight: CDBWrapper::Read’s own try has no way to tell “the checksum passed but the bytes are not a Coin” apart from “nothing is stored here” before it ever answers false for either, so a local deserialize bug surfacing as a rejected valid block is the accepted cost of the one read path that keeps every other cause of “missing” simple. This tree now pays the identical cost, for the identical reason: matching Core’s behaviour end to end rather than only the half of it that reads comfortably (CLAUDE.md’s own Following Bitcoin Core argues the same trade for CDBWrapper::Read in general).

message and not a structured payload per call site: what is inconsistent (a hash, a count, a status) differs by call site, and every one of them is read exactly once, in whatever it raises to.

Unlike most of this file’s classes, this one does not always end the node: update_chain’s own reorg reconciliation lets it propagate and end the node – reasonably so, since every raise above fires mid-way through applying a fork to this node’s own already-committed chainstate, and continuing to run this node once an index has been found disagreeing with its own data mid-mutation is not safe. The p2p filter callbacks (get_cfilters, get_cfheaders, get_cfcheckpt in p2p/callbacks.py) raise it too, over a filter or a filter header missing for a block already on the active chain, and carry none of that risk – nothing downstream of answering one peer’s BIP157 request is mid-mutation of anything. handle_p2p’s own generic catch is what lets them answer this without ending the node: it stops that one connection without discouraging the peer, isinstance(e, BTClibException) being false for it.

exception btclib_node.exceptions.IncompatibleStoreError(message: str)[source]

Bases: RuntimeError

A data directory holds a store this version cannot open.

Two cases raise this: a directory db.KeyValueStore wrote a sqlite3 file into, the format this class replaced (#107, #641) and this one cannot read, and a KeyValueStore written by a version that kept a different shape under one of its keys or in a block_db flat file – db.py’s own _SCHEMA_VERSION is where that second case is checked and argued.

exception btclib_node.exceptions.IncompleteRequestHeadError[source]

Bases: BTClibRuntimeError

parse_request_head was handed octets with no header terminator yet.

IncompleteMessageError’s own reason applies here unchanged: a connection reading its header section a chunk at a time is the ordinary case, not a hostile one, so this is BTClibRuntimeError and not BTClibValueError – more octets can still answer it, where the errors below cannot. RpcConnection.run never triggers this itself, since it only calls parse_request_head once _recv_until has already confirmed the terminator is present; it exists for parse_request_head’s other caller, fuzz/fuzz_rpc_head.py, which hands it whatever octets the fuzzer drew.

exception btclib_node.exceptions.InvalidBlockInputError(message: str)[source]

Bases: ValueError

A candidate block’s own transactions fail a UTXO consistency check.

Raised only by UtxoIndex.add_block, while it is walking a freshly-downloaded candidate block’s own transactions against the UTXO set for the first time – before check_transactions (script and signature checks) even runs, per update_chain’s own sequence in main.py. An input spending an output this index cannot find, or spending one a transaction earlier in the same block already spent, is exactly what a malicious or malformed block looks like, not a bug in this node’s own bookkeeping – ValueError, like MissingPrevoutError above (the same failure, checked at a different point: mempool reprocessing after a reorg, not connecting a new block), and not ChainstateInconsistencyError, whose whole point is the opposite claim.

exception btclib_node.exceptions.InvalidChainTypeError(chain: object)[source]

Bases: TypeError

Config’s own chain is neither a Chain nor a str.

TypeError and not ValueError: this is the argument being the wrong type entirely, which TRY004 is what noticed the tree already had a ValueError for – unknown chain, UnknownChainError above, is the right type with the wrong value, and stays one.

exception btclib_node.exceptions.InvalidRejectPayloadError(detail: str)[source]

Bases: BTClibValueError

A peer’s reject payload is not the message BIP61 describes.

Raised only by Reject.parse (p2p/messages/errors.py), over octets a peer chose: a field the payload is too short to hold, a message or a reason no utf-8 decodes, a code outside the set BIP61 names, or a trailing hash that is neither absent nor the 32 octets of one.

BTClibValueError and not a plain ValueError, for WrongNetworkMagicError’s own reason above: handle_p2p (p2p/main.py) discourages the peer on isinstance(e, BTClibException) and reads anything else as this node’s own code failing on content that was fine, so a refusal of a peer’s octets outside that family is logged against the wrong party.

exception btclib_node.exceptions.MalformedRequestHeadError(detail: str)[source]

Bases: BTClibValueError

A request’s header section names a Content-Length this node refuses.

Not present, defaults to 0 (RpcConnection.run’s own prior behaviour); present but not an integer, negative, or past rpc.connection.MAX_BODY_BYTES all raise this instead. BTClibValueError and not a plain ValueError, for WrongNetworkMagicError’s own reason above: RpcConnection.run’s catch is a bare except Exception, so this class only matters where a caller narrows on BTClibException the way fuzz/fuzz_rpc_head.py and tests/fuzz_corpus_test.py’s own _parsed both do.

exception btclib_node.exceptions.MissingPrevoutError[source]

Bases: ValueError

A transaction’s input spends an output this node cannot find.

Raised only by verify_mempool_acceptance (main.py), while it walks a candidate mempool transaction’s own inputs against the UTXO set and the mempool together and neither has the prevout – InvalidBlockInputError below is the same check, made instead while a freshly-downloaded candidate block is first connected.

exception btclib_node.exceptions.NodeShutdownTimeoutError(message: str)[source]

Bases: TimeoutError

Node.stop()’s own join outlived STOP_TIMEOUT.

The thread it waited for is still running once this is raised, so – unlike ChainstateInconsistencyError – nothing downstream of this call can trust the chainstate or the databases the wedged thread might still be writing.

exception btclib_node.exceptions.PrevoutCountMismatchError[source]

Bases: ValueError

check_transactions was handed a prevout list the wrong length.

exception btclib_node.exceptions.ReimportedMainProcessError(process_name: str)[source]

Bases: RuntimeError

Node() ran off the main process without saying that was meant.

Raised where multiprocessing.current_process().name is not “MainProcess” and the active start method is not fork – exactly the shape a Pool worker’s own bootstrap produces (multiprocessing.spawn.import_main_path re-importing __main__ to find the target it was asked to run), which is what let scripts/chains/*.py build a second Node on the same data directory in every worker Node.worker_pool spawned before those three scripts guarded their own module body (issue #579). This is the same failure caught one layer up, for every caller rather than only the three this tree ships (issue #589) – unless the caller passed Node(…, allow_reimported_main=True), which this class never sees raised against, since Node.__init__ checks that flag before either of the two calls this docstring names. A caller that reaches this exception has not opted in, so the two things the message offers – a module-body guard, or that same flag – are both live for it, whichever this actually was.

exception btclib_node.exceptions.StoreClosedError(message: str)[source]

Bases: ValueError

A KeyValueStore method was called after close().

ValueError, matching the standard library’s own io objects: reading or writing a closed file raises ValueError: I/O operation on closed file, not a bespoke class, and a KeyValueStore is the same shape of resource.

exception btclib_node.exceptions.StoreCorruptionError(message: str)[source]

Bases: RuntimeError

The store itself found its own bytes unreadable, at a read.

Raised only by db.KeyValueStore, at every point it reads from the RocksDB store beneath it – get, __iter__, and the open itself – on a rocksdict exception whose message begins Corruption:. rocksdict gives no typed class for that fault (only DbClosedError is typed, per its own .pyi), so db.py’s own classification is a string match on the message, argued where it sits.

A KeyValueStore is PeerDB’s own store as well as every chainstate index’s, so this is deliberately not ChainstateInconsistencyError: a corrupted address book is a p2p concern, not a chainstate one, and folding the two into one class would make every catch of it answer a question about which store it was. RuntimeError, matching ChainstateInconsistencyError’s own choice and Core’s dbwrapper_error (src/dbwrapper.h), for the same reason: whichever caller this reaches, continuing to run past a store that has just reported disagreeing with its own bytes is not safe, and nothing here narrows that per call site the way ChainstateInconsistencyError’s own call sites, which sometimes let it propagate without ending the node, do.

db.py’s own module docstring is where the checksum this class detects is argued against Core’s verify_checksums = true (btclib-org/btclib-node#641, closing btclib-org/btclib-node#637). Neither of UtxoIndex’s two chainstate callers maps this onto ChainstateInconsistencyError: add_block and get_coin both call self.db.get unguarded, so a genuine StoreCorruptionError there propagates as itself to whatever each caller’s own caller does with an exception outside its own – update_chain’s trial loop for add_block, verify_mempool_acceptance’s own callers for get_coin. ChainstateInconsistencyError’s own docstring is where the distinct, now-resolved question sits: what a Coin.parse failure means once this guard has already passed a record as intact (btclib-org/btclib-node#650).

exception btclib_node.exceptions.UnknownChainError(chain: str)[source]

Bases: ValueError

Config’s own chain string names no chain this tree knows.

exception btclib_node.exceptions.UnsupportedAddressTypeError[source]

Bases: ValueError

dial was asked to connect an address family it does not speak.

Every address dial reaches has already passed a network filter upstream (only the two families _IP_NETWORKS names are ever handed to it), so reaching here is that filter’s own invariant broken, not a peer’s address.

exception btclib_node.exceptions.WrongNetworkMagicError(magic: bytes)[source]

Bases: BTClibValueError

A message’s magic names a chain other than the one this node runs.

BTClibValueError and not a plain ValueError: Connection.run’s own catch discourages the peer on isinstance(e, BTClibException), the same test it uses for Message.parse’s own refusals, and this is the network-magic check that runs right after – both are the peer’s envelope being wrong, and both have to satisfy the same isinstance for the same reason.

btclib_node.interpreter module

Script and transaction validation, dispatched across Node.worker_pool.

get_flags reads which script rules are active at a given height off Config.chain.flags; check_transactions fans a block’s inputs out across the worker pool and warm is what a fresh worker process runs once, under Node.worker_pool’s process arm, on Node.warm_worker_pool’s dispatch, so the cost of importing btclib.script.engine is paid before a real check ever needs it (btclib-org/btclib-node#262). Under the thread arm _pool_factory picks on a free-threaded interpreter (btclib-org/btclib-node#388), that import is already paid by the time this module’s own is, so warm still runs there but has nothing left to pay for.

btclib_node.interpreter.check_coinbase_maturity(prevouts: list[Coin], spend_height: int) None[source]

Refuse a spend of a coinbase output not yet COINBASE_MATURITY deep.

Core’s bad-txns-premature-spend-of-coinbase (Consensus::CheckTxInputs, src/consensus/tx_verify.cpp:185-186, at bitcoin/bitcoin@204256c73f): nSpendHeight - coin.nHeight < COINBASE_MATURITY. Called once per transaction rather than once per block, because spend_height is not the same number for both of this tree’s own callers: main._validate_block passes the height of the block connecting the spend, and main.verify_mempool_acceptance passes one past the active chain’s own tip – the height a mempool transaction would have if it were mined next, matching Core’s own AcceptToMemoryPoolWorker (src/validation.cpp:897, same commit).

btclib_node.interpreter.check_coinbase_value(coinbase: Tx, transaction_data: list[tuple[list[Coin], Tx]], index: int, node: Node) None[source]

Refuse a coinbase paying more than the subsidy plus the fees it collects.

Core’s bad-cb-amount (ConnectBlock, src/validation.cpp:2619-2621, at bitcoin/bitcoin@204256c73f): nFees + GetBlockSubsidy(…) is the ceiling. The fee sum is recomputed here from transaction_data’s own prevouts and outputs – the same shape main.verify_mempool_acceptance already uses to recover a single transaction’s own fee – rather than threaded out of verify_amounts above, which returns nothing.

btclib_node.interpreter.check_final_transactions(transactions: list[Tx], height: int, block_time: int) None[source]

Refuse a block carrying a transaction that is not final.

Core’s bad-txns-nonfinal (ContextualCheckBlock, src/validation.cpp:4158-4166, at bitcoin/bitcoin@204256c73f): every transaction the block carries, coinbase included – unlike check_sequence_locks below, which Core itself only ever asks of the non-coinbase ones. block_time is the cutoff is_final_tx checks lock_time against: main._validate_block’s and main.verify_mempool_acceptance’s own docstrings say what each passes and why.

btclib_node.interpreter.check_sequence_locks(transaction_data: list[tuple[list[Coin], Tx]], height: int, *, enforce_bip68: bool, tip_median_time_past: int, ancestor_median_time_past: Callable[[__annotationlib_name_1__], __annotationlib_name_2__]) None[source]

Refuse a non-coinbase transaction whose BIP68 relative lock is unmet.

Core’s SequenceLocks/CalculateSequenceLocks/EvaluateSequenceLocks (src/consensus/tx_verify.cpp:45-115, at bitcoin/bitcoin@204256c73f), over each input’s own Coin.height rather than a freshly-read CCoinsViewCache – the same prevouts check_transactions above already carries per transaction, so this reads them rather than asking the UTXO set again.

enforce_bip68 is Core’s own DeploymentActiveAt(pindex, …, DEPLOYMENT_CSV): this tree has no BIP9 deployment tracking of its own, so main.py’s own callers pass whether “CHECKSEQUENCEVERIFY” is active in Chain.flags instead – sound because Core deploys BIP68, BIP112 and BIP113 together as one soft fork, so the height that turns on the opcode is the height that turns on this. A transaction below version 2, or an input whose sequence carries _SEQUENCE_LOCKTIME_DISABLE_FLAG, is skipped rather than refused, matching BIP68.

tip_median_time_past is Core’s own block.pprev->GetMedianTimePast() – the reference a height-based lock is compared against directly, and a time-based one after ancestor_median_time_past has already turned each input’s own relative lock into an absolute one. ancestor_median_time_past(h) returns the median time past of the block at height h; time-based locks are measured from the block before the one that confirmed the coin (max(coin.height - 1, 0)), matching Core’s own comment on why – “the smallest allowed timestamp of the block containing the txout being spent”.

btclib_node.interpreter.check_transaction(prevouts: list[TxOut], tx: Tx, index: int, node: Node) None[source]

Verify one transaction against its prevouts, on the caller’s own thread.

Not routed through Node.worker_pool, unlike check_transactions above: this runs once per mempool acceptance rather than once per block’s worth of inputs, so the pool’s own process-pickling cost would outweigh what it buys here.

btclib_node.interpreter.check_transactions(transaction_data: list[tuple[list[Coin], Tx]], index: int, node: Node) None[source]

Verify a candidate block’s own transactions, fanned out across the pool.

Raises on the first bad input node.worker_pool.starmap reaches – main.update_chain’s own caller is what rolls the chainstate back and leaves the block off the active chain once this does. Amounts are checked here, per transaction and outside the pool, since script validation alone never reads them. transaction_data carries each prevout as a Coin – what check_coinbase_maturity below needs of it – and every btclib call here wants a bare TxOut, so each is unwrapped where it is used rather than threaded through as two parallel lists.

btclib_node.interpreter.f(prevouts: list[TxOut], tx: Tx, i: int, flags: tuple[str, ...], precomputed: PrecomputedTxData) None[source]

Verify input i of tx against its own prevout, one starmap task.

btclib_node.interpreter.get_flags(config: Config, index: int) tuple[str, ...][source]

Return every script flag already active at block height index.

config.chain.flags is a chain’s own (height, name) pairs, ordered by activation height; a flag activated at or before index is one that applies to a block at that height and every one after.

btclib_node.interpreter.is_final_tx(tx: Tx, height: int, block_time: int) bool[source]

Whether tx is final at height, against a cutoff of block_time.

Core’s IsFinalTx (src/consensus/tx_verify.cpp:23-42, at bitcoin/bitcoin@204256c73f): a zero lock_time is always final; otherwise it is a block height below _LOCKTIME_THRESHOLD and a unix timestamp at or above it, and tx is final once height or block_time – whichever lock_time’s own units name – has passed it. Still final regardless, if every one of tx’s own inputs opts out of lock_time by carrying _SEQUENCE_FINAL: OP_CHECKLOCKTIMEVERIFY depends on this escape hatch never firing for an input it itself guards, which is why it also refuses a final sequence on its own input (btclib.script.engine.script_op_codes.op_checklocktimeverify).

btclib_node.interpreter.warm() None[source]

Do nothing, once a worker has imported this module to run it.

Node.warm_worker_pool dispatches several of these across the pool so that every worker pays the import of this module – and of btclib.script.engine above, the expensive part of it – before check_transactions below ever needs one of them for real (btclib-org/btclib-node#262). Only a genuine cost under Node.worker_pool’s process arm: a worker thread shares the one import its own process already paid, so the dispatch reaches it too but finds nothing left to do (btclib-org/btclib-node#388).

btclib_node.log module

Logger, a logging.Logger writing to a file or to a stream.

A file handler where a caller names a path – Node.__init__ resolves one under Config.data_dir when Config.log_path is set – a stream handler otherwise, and close to release whichever one it opened.

class btclib_node.log.Logger(log_path: __annotationlib_name_1__ | Path | None = None, *, debug: bool = False)[source]

Bases: Logger

A logging.Logger writing to log_path, or a stream if unset.

close() None[source]

Close and detach every handler __init__ attached.

btclib_node.main module

update_chain, called once per pass of Node’s own loop.

Builds a fork’s contextual detail, validates it block by block through interpreter.check_transactions, reconciles the mempool across whatever it adds and removes, and announces every added block to every connected peer. verify_mempool_acceptance is the same validation path entered from a single transaction instead, for the RPC and p2p callbacks that relay one.

btclib_node.main.parent_lookup(node: Node) Callable[[BlockHeader], BlockHeader][source]

Return a callable stepping from a known header back to its parent’s.

_validate_block and verify_mempool_acceptance below, and rpc.callbacks.get_blockchain_info, each need this for median_time_past: header_dict holds every header this node has ever indexed, active chain or not, so this reaches a trial fork’s own earlier blocks as readily as long-committed history – unlike active_chain, which still reads as the chain before this trial until _finalize_fork runs. Not underscore-prefixed: rpc.callbacks is a different module, and importing a name it does not own would be the private-name import this codebase’s own ruff configuration (select = [“ALL”]) already refuses elsewhere.

btclib_node.main.prune_up_to_height(node: Node, target_height: int) None[source]

Delete block and undo data up to target_height, clearing downloaded.

The one write path _prune_chain’s own automatic-target walk below and rpc.callbacks.prune_blockchain’s manual call share: both need the same pairing, in the same order.

Core’s own BlockManager::PruneOneBlockFile (node/blockstorage.cpp:270-286, at bitcoin/bitcoin@ca7162cde5) clears BLOCK_HAVE_DATA/BLOCK_HAVE_UNDO on the CBlockIndex entry it prunes, “any block we prune would have to be downloaded again in order to consider its chain” – matched here by clearing BlockInfo.downloaded for the same range block_db.prune_up_to below is about to delete, over block_db.pruned_up_to the same way that call’s own idempotency check is, before the data itself is gone. p2p.callbacks.block’s own no-op-if-downloaded guard is the reader this matters to: without this, a block re-offered after its data was pruned would be silently discarded rather than re-stored.

Never clears height 0: BlockIndex.__init__ seeds genesis with downloaded=True and it is never written to block_db in the first place (chain.genesis is known outright, not fetched), so range below starts at max(1, …) rather than at pruned_up_to + 1 unguarded – a target_height of 0 would otherwise clear a flag for a block this store never held and never asks a peer for again.

btclib_node.main.update_chain(node: Node) None[source]

Try the best ready fork block by block, and commit or roll it back.

Called once per pass of Node’s own loop. _ready_fork answers whether there is a fork worth trying at all; if there is, every block on it is applied to the UTXO set and validated in turn, a shutdown between two blocks stopping the trial without failing it. Every other exception rolls every index back to where it stood before this call; whether it also invalidates the block it happened on, or instead propagates out of this call once the rollback has run, is _CONTENT_FAILURE’s own distinction above. Once a trial succeeds, _finalize_fork commits it, the mempool is reconciled against whatever it added and removed, and _announce_added_blocks tells every connected peer.

btclib_node.main.verify_mempool_acceptance(node: Node, tx: Tx) int[source]

Verify a transaction against its prevouts and return its fee.

The fee is the same sum-of-inputs-less-sum-of-outputs btclib.script.engine.verify_amounts already computes and discards inside check_transaction below; recomputed here from the same prev_outputs this function built for that call, rather than threaded back out of btclib’s engine, which returns nothing. btclib-org/btclib-node#260

Checks finality and BIP68 against the tip Core’s own mempool policy does (CheckFinalTxAtTip/STANDARD_LOCKTIME_VERIFY_FLAGS, src/validation.cpp:156-175 and policy/policy.h:137, at bitcoin/bitcoin@204256c73f), both unconditionally rather than gated on any activation height, unlike main._validate_block’s own block-connect path: a mempool never holds a transaction from before a soft fork it has already activated, so Core’s own mempool code does not ask either.

btclib_node.mempool module

Mempool, this node’s set of transactions not yet in a block.

Reached from Node’s own thread alone – add_tx and remove_tx are called from the p2p callbacks, the rpc callbacks and main.update_chain, never from P2pManager’s or RpcManager’s own asyncio loop – so it carries no lock of its own. The rolling minimum feerate an eviction round leaves behind decays the way Core’s own does, _ROLLING_FEE_HALFLIFE below being ROLLING_FEE_HALFLIFE (src/txmempool.h).

class btclib_node.mempool.Mempool(logger: Logger)[source]

Bases: object

The node’s set of transactions not yet in a block, keyed both ways.

transactions is by wtxid, txid_index maps a txid to the wtxid that holds it, and fees carries what each entry paid – the module docstring above is where the single-thread invariant that lets this class carry no lock of its own is argued. spent_by is the fourth index, _descendants below is where it is read; _feerate_heap is the fifth, _pop_worst_wtxid below being where it is read and _rebuild_feerate_heap where it is kept from growing without bound. _heap_current_seq is the sixth, and is what tells a heap entry for a wtxid still held apart from one superseded by a later re-add of the same wtxid – _pop_worst_wtxid again being where that is read.

add_tx(tx: Tx, fee: int = 0) bool[source]

Add tx, evict past the limit, and say whether it stuck.

A no-op, returning False, for a txid already held. Otherwise added provisionally and run through _evict_to_limit, which takes it right back out if it is itself the worst entry left once trimming is done – so the return value is False there too, exactly as it would be for an outright refusal.

contains_tx(tx: Tx) bool[source]

Whether tx’s own wtxid is currently held.

get_min_fee_rate() FeeRate[source]

Return the rolling minimum feerate, decayed since it last moved.

Core’s own GetMinFee (src/txmempool.cpp:877, same commit), with sizelimit read from self.bytesize_limit rather than threaded through as an argument, since this mempool already owns that number instead of a caller supplying it each call.

No decay at all until a block has passed since the value last rose (_block_since_last_rolling_fee_bump) – an eviction round with no block in between only ever raises it, _track_package_removed’s own guard. Past that, every 12-hour half-life (_ROLLING_FEE_HALFLIFE) erodes it toward zero, the half-life itself shortened while this mempool is well under its limit – self.bytesize standing in for Core’s own DynamicMemoryUsage(), both being how full the mempool actually is rather than how many transactions it holds – and the value floored at _INCREMENTAL_RELAY_FEE_RATE once it decays, or zeroed once it decays under half of that: below that floor it is not a small minimum, it is none.

round rather than Core’s own llround (ties-to-even against ties-away-from-zero) is the one place this departs from GetMinFee’s own arithmetic – a tie only a decayed float lands on exactly, and advisory relay policy this module does not thread through consensus does not need closed to the bit.

get_missing(transactions: Iterable[__annotationlib_name_1__], *, wtxid: bool = False) list[bytes][source]

Return every id in transactions this mempool does not hold.

get_tx(txid: bytes, *, wtxid: bool = False) Tx | None[source]

Return the transaction stored under txid (or wtxid), or None.

is_full() bool[source]

Whether bytesize has already reached bytesize_limit.

meets_fee_rate(wtxid: bytes, min_fee_rate: int) bool[source]

Whether the entry’s own fee clears a rate quoted in sat/kvB.

BIP133’s own comparison – Core’s txiter->GetFee() < filterrate.GetFee(txiter->GetTxSize()), net_processing.cpp – against this mempool’s own record of what the transaction paid, rather than recomputing it at relay time. min_fee_rate of zero, BIP133’s and Connection.feefilter’s own “no filter” value, always clears; so does a wtxid this mempool holds no fee for – already relayed out of Mempool.add_tx’s own default, evicted, or gone from the mempool for any other reason by the time this is asked – since there is nothing here to withhold it for. A caller relaying only what this mempool still holds is download.py’s own responsibility, checked there rather than assumed here: btclib-org/btclib-node#294.

note_block_connected() None[source]

Restart the rolling minimum’s decay clock for one connected block.

Core’s own removeForBlock (src/txmempool.cpp:405-427, same commit) sets lastRollingFeeUpdate/blockSinceLastRollingFeeBump this way for every block, whether or not that block held any transaction this mempool was also holding – called once per block from main.update_chain’s own connect loop, and not folded into remove_tx, which already runs once per transaction inside that same loop rather than once per block.

remove_tx(tx: Tx) None[source]

Remove tx by txid, a no-op if this mempool does not hold it.

Module contents

Node, the thread that drives everything else in this package.

One loop: drain the handshake queue, then a share of the RPC queue and a share of the peer-to-peer queue, then step the download manager and extend the chain. A message that raises is logged and the loop continues; a failure under update_chain leaves the loop, because the databases the submodules below open have to be closed on the way out.

P2pManager and RpcManager are each a thread of their own, running an asyncio loop of their own; this module is what calls into them and what they hand work back to.

class btclib_node.Node(config: Config | None = None, *, allow_reimported_main: bool = False)[source]

Bases: Thread

A bitcoin full node, and the thread that runs its main loop.

config (or a default Config when none is given) is what says which chain, which data directory and which ports; __init__ opens every database under that directory and wires the p2p and RPC managers to this node before start() ever runs run’s loop.

Building one touches no process-wide state: install_signal_handlers below is the separate, explicit call a caller makes for that, and this object never makes it on its own behalf (issue #436).

__init__ also refuses outright inside a re-imported __main__ (ReimportedMainProcessError below) unless allow_reimported_main says otherwise, rather than leaving that to a module-body if __name__ == “__main__”: guard every future caller has to remember on its own (issue #589). The check cannot tell the accident that guard prevents from a deliberate supervisor building a Node inside its own pool worker – both look identical from inside __init__, so the distinction has to come from the caller, and allow_reimported_main=True is how it says so.

run() None[source]

Method representing the thread’s activity.

You may override this method in a subclass. The standard run() method invokes the callable object passed to the object’s constructor as the target argument, if any, with sequential and keyword arguments taken from the args and kwargs arguments, respectively.

stop() None[source]

Ask the main loop to stop, and wait up to STOP_TIMEOUT for it.

Raises if the loop has not come back by then, the node having no way to be sure of its chainstate or its databases while a thread is still inside them.

Signalling alone lets the caller go on while the node is still there. A test that returns then is torn down around a thread that goes on logging into a harness that has moved on, which is where ValueError: I/O operation on closed file came from; and a loop that cannot come back at all holds the interpreter open after the last test, this being a non-daemon thread. No per-test limit reaches that second one – the test it belongs to has already passed – so waiting here is what puts the wait inside the test, where a limit can name it (btclib-org/btclib-node#98).

The bound is the point rather than a precaution: pytest-timeout arms one timer per test, so a limit already spent in the call phase is not there for the teardown, and an unbounded wait in a teardown is a run that stops instead of failing (btclib-org/btclib-node#115).

The node’s own thread is the one caller that cannot wait, the stop RPC being handled inside the loop it stops. It gets the signal alone, and reaches the end of run by itself. A signal handler is the other caller worth naming: this raising there makes an operator’s interrupt loud, and it does not make the process able to exit, the wedged thread being non-daemon.

warm_worker_pool() None[source]

Build the worker pool now, on a thread of its own, and warm it.

check_transactions’ own first call used to be what built and warmed worker_pool, on whatever thread called it – run’s own loop below, the same one that drains p2p_manager.handshake_messages and promotes a connection once its verack arrives. Under _pool_factory’s process arm, each of the pool’s own processes pays its own import of btclib_node.interpreter (and, through it, btclib.script.engine) the first time it is dispatched a task, and while that first dispatch is running, the loop below cannot drain that queue: a peer whose verack the kernel already delivered sits unpromoted until the call returns (btclib-org/btclib-node#262). Under the thread arm every worker already shares the one import this process paid when Node itself was imported, so the same dispatch below costs this module nothing there – and is still made, both arms being one call site and the warm-up being harmless where it is not needed.

download_manager.block_download is the only caller, right before it sends the first real GetData for a block this node does not have – the earliest point a script is actually going to be validated, with a peer’s round trip ahead of it as extra runway, rather than the moment header sync merely completes. Reaching HeaderSynced is not enough on its own: the comment on _worker_pool above is that a node which never validates a script should not pay for the pool, and a node whose headers are synced but which never has a block to fetch – a header-only peer under test, a peer whose counterpart stops serving blocks – is exactly that. The guard below makes a second call a no-op, since block_download runs on every pass of the loop below and would otherwise ask for a second thread once the first has already built the pool.

property worker_pool: Pool

The pool interpreter.py validates a script in, built on first use.

_pool_factory picks the type against sys._is_gil_enabled() read here, once, rather than inside that function: a Pool under a GIL build, a ThreadPool under a free-threaded one (issue #388). Under the lock, so that two callers building it at once get one pool between them: the second would otherwise leave a pool with nothing holding it and nothing to terminate it.

btclib_node.install_signal_handlers(node: Node) None[source]

Stop node on SIGINT, SIGTERM and SIGTSTP, process-wide.

signal.signal keeps one handler per signal per process, replacing whatever was there before, so this is for the one caller in a process that wants an operator’s interrupt to reach a node at all – cli.py’s own main. Calling it a second time, for a second node, replaces the first node’s handler rather than adding to it: that is the same signal.signal the first call made, not a defect this function introduces.

Kept out of Node.__init__ for two reasons (issue #436). A second Node built in one process used to silently disown the first, every call installing a fresh handler bound to the newer node with nothing to say the first one’s databases were still open behind it – every functional p2p test builds two nodes, so the first node’s handlers survived only for the length of the second one’s constructor call. And signal.signal raises outside the main thread of the main interpreter, so a Node could not be built at all from a worker thread, whether or not that caller ever wanted a process-wide interrupt.

SIG_DFL/SIG_IGN are signal.signal’s own other two handler arguments, POSIX’s only alternatives to a callable one; every handler is called with (signum, frame), unread here since the three signals below share one handler and stop takes neither. SIGTSTP is for hibernation and does not exist on Windows (btclib-org/btclib-node#429): the signal module itself carries no SIGTSTP attribute there, CPython’s own Lib/signal.py defining it conditionally, so hasattr is what keeps the attribute access this call needs from raising before this function ever reaches its return – SIGINT and SIGTERM registering correctly ahead of it was not enough to save a caller that let this propagate (btclib-org/btclib-node#430).