Architecture

btclib-wallet is what a wallet does on top of btclib: from a seed to a signed, broadcast transaction. This page is its high-level design: which module holds what, the one dependency direction, and every place a module crosses out of the process it runs in. What a user can expect of it in terms of security is SECURITY, and why those expectations hold is the assurance case.

The dependencies

btclib is the protocol: the curve arithmetic, the signature schemes, the codecs, scripts and transactions. This package imports it and re-exports nothing of it — a name btclib defines is imported from btclib, never from here — and nothing in btclib imports this package. tests/all_test.py holds the first half: a module exporting a name it does not define fails it unless REEXPORTED records the name, and no entry there is btclib’s; tests/imports_test.py adds that every import of a unit that moved here names this package rather than btclib’s copy. The second half is btclib’s own: its pyproject.toml does not depend on this package, and test_btclib_imports_nothing_of_btclib_wallet in tests/imports_test.py holds it here. The edge is btclib-org/btclib#2129’s table, row 5 — btclib_wallet, under src/btclib_wallet/ — sitting above btclib, row 4, and depending on it and on bitcoin-core-rpc, row 6: dependencies point one way, down that table, so nothing in btclib or in bitcoin-core-rpc imports this package either. btclib_wallet.fetch publishes bitcoin-core-rpc’s clients and transport under its own names, and tests/all_test.py’s REEXPORTED records each of them.

This package also depends on ecc directly — row 2 of that table — because every package declares every distribution whose names it imports rather than relying on btclib’s re-export of them; pyproject.toml’s comment beside the dependency names what this package imports from it.

It depends on bip39-slip39 too, which holds the BIP39, SLIP39 and Electrum mnemonic schemes from the entropy to the seed and imports nothing of btclib or of this package. What stays here is where a seed meets BIP32, which only a package seeing both sides can hold: the functions of mnemonic/ below. A module here reaches a name of bip39_slip39 through that package, bip39_slip39.bip39.seed_from_mnemonic, and neither binds nor re-exports it, which tests/all_test.py holds: a caller reaches the schemes by that package’s name.

The line between this package and btclib is Bitcoin Core’s between src/consensus and src/wallet: what places a module here is everything from a seed to a signed, broadcast transaction, and every module with a counterparty outside the process — a socket, a subprocess, a node, a device — where the codec of a protocol opens nothing, and stays btclib’s unless this package is its only user, as it is of Electrum’s.

What this package signs, it signs through ecc.ecc, so the dispatch to the libsecp256k1 bindings (ecc.curves.curve._libsecp256k1_serves) and where constant time ends are ecc’s, stated in its SECURITY under Limitations, not vulnerabilities. src/btclib_wallet/bip32/bip32.py and src/btclib_wallet/silent_payments.py are the two modules that reach past that dispatch and call the btclib_secp256k1 bindings directly, for operations the general dispatch does not cover; ASSURANCE_CASE.md names each call.

btclib_wallet/__init__.py’s __all__ is the root of the public tree, written out rather than discovered; nothing is imported eagerly, and a submodule loads on first attribute access through the package’s own __getattr__, so import btclib_wallet alone pulls in none of it.

From a seed to a key

  • bip32/ derives extended keys along a BIP32 path, and bip32.der_path and bip32.key_origin are its two notations — a der_path string and the fingerprint-plus-path pair a psbt or a descriptor carries.

  • mnemonic/ builds the BIP32 master key of a BIP39, SLIP39 or Electrum mnemonic from the seed bip39_slip39 recovers from it, and the master public key of a pre-2.0 Electrum one.

  • bip85.py derives another wallet’s entropy — a BIP39 sentence among other formats — from one BIP32 root, so one backup stands behind several keychains that share no key with each other.

  • slip132.py and bip44.py turn an extended key into an address: SLIP132’s key-version table for which curve and script type an xpub prefix names, BIP44’s m/purpose'/coin_type'/account'/change/index for which key a path derives. bip44.account_descriptors builds the receive and change descriptors of an account from that purpose, out of descriptors’ fragments, so bip44 imports descriptors. descriptors imports nothing of bip44: what a path means under BIP44 is no part of the descriptor language.

  • bip38.py and minikey.py spell a single private key that no seed derives: BIP38 encrypts it under a password with scrypt and AES, and a Casascius minikey is a short string whose sha256 is the key itself.

Watching, not holding: descriptors and PSBTs

  • descriptors/ reads BIP380’s output-descriptor grammar — descriptors.key_expression for the KEY expressions and descriptors.miniscript for BIP379’s SCRIPT language, a tree of fragments a descriptor’s own module composes into the scripts an account produces and the Expansion a psbt’s Updater writes — and hands back what a wallet needs to watch somebody else’s keys, never a private one of its own.

  • psbt/ is BIP174 and BIP370: the three maps a psbt is made of, the Updater over a descriptor, the Combiner, the Finalizer and the Extractor, and the size estimation a fee rate is applied to. psbt.musig2, psbt.frost and psbt.silent_payments are the later BIPs’ own roles over the fields each adds — BIP373, BIP445 and BIP375 respectively — frost’s own fields being proprietary records under a btclib identifier until a BIP assigns them the bytes.

  • tx_or_psbt.py decodes hex or base64 itself, ASCII whitespace removed, sniffs BIP174’s five-byte magic and dispatches to Tx.parse or Psbt.parse accordingly, so a caller holding hex, base64 or raw bytes from an unknown source does not answer that question itself.

  • tx_builder.py composes a psbt at a fee rate with change, out of parts that live elsewhere — Psbt.prevouts, Psbt.vsize_estimate, btclib.fee.fee_from_vsize, btclib.fee.dust_threshold — and coin_selection.py is what chooses the inputs it spends: Bitcoin Core’s own three algorithms, branch_and_bound, knapsack and single_random_draw, select_coins running whichever the caller names — every one of them by default — and keeping the result of lowest waste, Core’s own SelectionResult::GetWaste metric.

  • core_import.py is the request Bitcoin Core’s importdescriptors takes, built and validated here so that a wrong field is caught before it reaches a node — no RPC call and no client of its own, the caller already holding one.

The wallets, and the state they remember

wallet/wallet.py holds the vocabulary every wallet in the package answers to — which addresses have been handed out, what is remembered about each, whether any private key is held — and no key, descriptor or script of its own. wallet.key_wallet.KeyWallet derives no address from a position and holds individual keys instead; wallet.descriptor_wallet and wallet.script_wallet are addressed by the BIP44 branch and index a RangedWallet puts below an account, one from an output descriptor and one from a script template.

Discreet log contracts

dlc/ holds what a DLC needs offline: dlc.transactions builds its transactions from terms two parties agree on so that both get the same bytes, dlc.signing signs the CETs and the refund and completes all three kinds of transaction, and dlc.oracle reads, signs and checks what its oracle publishes. Each module’s docstring says what it holds. dlc/ opens nothing: the messages exchanged with the other party are the caller’s.

The signing boundary

psbt_signer.py declares the PsbtSigner protocol every external signer answers to — what a caller may ask and what has to come back — distinct from psbt.sign, which plays the Signer role over a KeyManager btclib calls in-process and whose answers are already btclib’s own. psbt_signer_contract.assert_psbt_signer checks an implementation of the protocol against it from outside, for any implementer: a command-line adapter, an in-process driver, a signing service. psbt_signer.SoftwareSigner is this package’s own in-process implementation, and hwi.py’s HwiSigner a second one that runs Bitcoin Core’s HWI as a subprocess, selecting a device by fingerprint and passing five of the protocol’s calls through HWI’s JSON command line; nothing of hwilib itself is imported, so the subprocess is the whole of the dependency.

bip322.py signs a message by satisfying the address’s script rather than by a key the verifier recovers, as btclib.ecc.bms does: the signature spends a virtual output paying to that address, and script.engine verifies it, so a taproot, p2wsh or multisig address can sign. fuzz/fuzz_bip322.py fuzzes its decoder.

Reaching outside the process

fetch/ is the one package that goes and asks where the chain is, and it answers in btclib’s own types, Tx, TxOut and BlockHeader. Fetcher is the interface, implemented once per backend — BitcoinCoreFetcher over a full node’s JSON-RPC, BitcoinCoreRestFetcher over the same node’s unauthenticated -rest interface, EsploraFetcher over a block explorer’s HTTP API, ElectrumFetcher over an Electrum server, CompactFilterFetcher over peers’ BIP157 filters — so calling code takes a Fetcher and never branches on which one it got. CompactFilterFetcher’s docstrings say what it serves of Fetcher and what its scan_blocks does. Broadcaster, FeeEstimator, BalanceReader and HistoryReader are further protocols a backend may or may not satisfy: BitcoinCoreRestFetcher answers none of them. Core’s -rest interface is read-only and carries no fee estimation and no address index, and the fetcher does not read a script’s history. fetch.transport is the client seam — one HTTP connection per call, one kept open across calls, and TlsLineTransport for the Electrum line protocol — and fetch.peer_transport is the seam of the p2p peer: TcpPeerTransport holds one TCP connection to it, plain and without authentication. The codec each backend speaks is btclib.p2p or electrum.py, outside fetch/: importing fetch costs urllib, ssl and socket, never the reverse.

exceptions.py declares the public classes this package raises and btclib does not, each derived from btclib’s class of its kind. A fetcher raises its FetchError, HttpError and RpcError. bitcoin-core-rpc declares classes of the same names, and fetcher.client_errors is where every fetcher re-raises them as this package’s. BitcoinCoreRpcClient and BitcoinCoreRestClient are re-exported unchanged rather than wrapped, so calling them directly raises bitcoin-core-rpc’s own exceptions.

bip21.py and bolt11.py/bolt9.py sit above every layer named here: a BIP21 URI names an address, an amount and, through lightning=, a BOLT11 invoice carrying BOLT9’s feature bits, and nothing else in the package imports them back.

What is delegated, and what is not

Every primitive — the curve, a signature, a script, a transaction’s serialization — is btclib’s or ecc’s, and a mnemonic up to its seed is bip39_slip39’s; this package reimplements none of it. What is here is the key-derivation schemes above btclib, the formats that cross a wallet’s own boundary — descriptors, PSBTs, extended keys, payment URIs — the wallets that remember what they have handed out, the one contract an external signer answers to, the transactions and signatures of a discreet log contract, and the clients that reach a node, an explorer, an Electrum server or a p2p peer for what the chain currently holds. tests/imports_test.py mirrors the edges above, and holds the layering inside the package: its _ALLOWED_UNITS names, for bip32/, psbt/ and each unit psbt/ reaches, the only other units of the package it may import. bip32/ and descriptors/ are what btclib-org/.github#1811 moves to a package of their own, so the tests, fuzz harnesses and scripts that move with them import nothing that stays but the test helpers a split copies. tests/imports_test.py holds that, and tests/moving_imports_test.py for the files the sdist does not carry. tests/fuzz_test.py holds every parser named here to one contract: it fails the way the library says it fails, whatever it is handed, never with a bare IndexError or OverflowError a caller’s except BTClibValueError does not catch. The harnesses under fuzz/ drive the extended-key, descriptor, PSBT and BIP322 parsers beyond that.