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, andbip32.der_pathandbip32.key_originare its two notations — ader_pathstring 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 seedbip39_slip39recovers from it, and the master public key of a pre-2.0 Electrum one.bip85.pyderives 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.pyandbip44.pyturn an extended key into an address: SLIP132’s key-version table for which curve and script type an xpub prefix names, BIP44’sm/purpose'/coin_type'/account'/change/indexfor which key a path derives.bip44.account_descriptorsbuilds the receive and change descriptors of an account from that purpose, out ofdescriptors’ fragments, sobip44importsdescriptors.descriptorsimports nothing ofbip44: what a path means under BIP44 is no part of the descriptor language.bip38.pyandminikey.pyspell 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 whosesha256is the key itself.
Watching, not holding: descriptors and PSBTs¶
descriptors/reads BIP380’s output-descriptor grammar —descriptors.key_expressionfor the KEY expressions anddescriptors.miniscriptfor BIP379’s SCRIPT language, a tree of fragments a descriptor’s own module composes into the scripts an account produces and theExpansiona 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.frostandpsbt.silent_paymentsare 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.pydecodes hex or base64 itself, ASCII whitespace removed, sniffs BIP174’s five-byte magic and dispatches toTx.parseorPsbt.parseaccordingly, so a caller holding hex, base64 or raw bytes from an unknown source does not answer that question itself.tx_builder.pycomposes 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— andcoin_selection.pyis what chooses the inputs it spends: Bitcoin Core’s own three algorithms,branch_and_bound,knapsackandsingle_random_draw,select_coinsrunning whichever the caller names — every one of them by default — and keeping the result of lowest waste, Core’s ownSelectionResult::GetWastemetric.core_import.pyis the request Bitcoin Core’simportdescriptorstakes, 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.