# Copyright (c) The btclib developers
# Distributed under the MIT software license, see the accompanying
# LICENSE file or https://opensource.org/license/mit for the full text.
"""`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
`feefilter` -- `DEFAULT_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.
"""
from dataclasses import dataclass
from ipaddress import ip_address
from pathlib import Path
from typing import TYPE_CHECKING
from btclib.fee import FeeRate
from btclib_node.chains import Chain, Main, RegTest, SigNet, TestNet
from btclib_node.exceptions import InvalidChainTypeError, UnknownChainError
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = ["DEFAULT_MIN_RELAY_FEERATE", "Config", "split_host_port"]
# Core's own floor, `DEFAULT_MIN_RELAY_TX_FEE` (`src/policy/policy.h`,
# read at bitcoin/bitcoin@58a7869f86): 100 sat/kvB. This node prices
# nothing at mempool acceptance yet (issue #85 is the open question of
# what a rejected or evicted transaction costs), so the value below is
# only ever the floor this node tells a peer about in `feefilter`
# (btclib-org/btclib-node#94) -- it is not enforced anywhere else.
DEFAULT_MIN_RELAY_FEERATE = FeeRate(sats_per_kvbyte=100)
# A named module-level singleton rather than `Main()` written straight
# into __init__'s own signature below: a call there is made once, at
# import time, and B008 is what a reader would otherwise have to notice
# on their own -- this is the fix ruff's own message suggests, and the
# shape `DEFAULT_MIN_RELAY_FEERATE` above already uses for the same
# reason.
DEFAULT_CHAIN = Main()
[docs]
def split_host_port(spec: str, default_port: int) -> tuple[str, int]:
"""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.
"""
host = spec
port = default_port
colon = spec.rfind(":")
if colon != -1:
bracketed = spec.startswith("[") and spec[:colon].endswith("]")
multi_colon = spec.rfind(":", 0, colon) != -1
if colon == 0 or bracketed or not multi_colon:
host, port_text = spec[:colon], spec[colon + 1 :]
try:
port = int(port_text)
except ValueError:
port = -1
if not 0 < port <= 0xFFFF: # noqa: PLR2004
err_msg = f"{spec!r} names an invalid port"
raise ValueError(err_msg)
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
return host, port
def _resolve_peers(
specs: Sequence[str], default_port: int
) -> tuple[tuple[str, int], ...]:
"""Split every spec in `specs` and check its host is a literal IP.
A hostname is not resolved here, unlike Core's own `-connect`/
`-addnode`, which dial through `CConnman::ConnectNode` and resolve
one via `Resolve` (`src/net.cpp`) same as any other peer. This
node's own dial route -- `p2p_manager.connect(peer_address(...))`,
the one ISS 573 (btclib-org/btclib-node#573) asks these two fields
to use -- takes a `NetworkAddressV2` built straight off a parsed IP
(`p2p/address.py`'s `peer_address`), and nothing in this node's
synchronous startup path resolves a name into one: the only DNS
lookup here is `PeerDB.get_addr_from_dns`'s own coroutine, on
`P2pManager`'s asyncio loop, which is not reachable before that
manager's thread exists. Widening `peer_address` or plumbing an
async resolve into `Node.run` for two config fields is a larger
change than this branch's scope; a hostname is refused up front,
at `Config` construction, rather than dialled wrong or silently
dropped later.
"""
peers: list[tuple[str, int]] = []
for spec in specs:
host, port = split_host_port(spec, default_port)
ip_address(host) # raises ValueError on a hostname or garbage
peers.append((host, port))
return tuple(peers)
def _resolve_chain(chain: Chain | str) -> Chain:
if isinstance(chain, Chain):
return chain
if not isinstance(chain, str):
raise InvalidChainTypeError(chain)
if chain == "mainnet":
return Main()
if chain == "testnet":
return TestNet()
if chain == "signet":
return SigNet()
if chain == "regtest":
return RegTest()
raise UnknownChainError(chain)
[docs]
@dataclass
class Config:
"""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`.
"""
chain: Chain
# a Path, which is what __init__ below stores and what every reader
# of it does path arithmetic on
data_dir: Path
# `None`, Core's own "default: <datadir>" (`-blocksdir=<dir>`'s own
# help text, `src/init.cpp:514`, at bitcoin/bitcoin@ca7162cde5),
# unless a caller names a base directory of its own for `BlockDB`'s
# files -- chain-suffixed here the same way `data_dir` above is,
# matching `ArgsManager::GetBlocksDirPath`'s own append of the
# chain-specific subdirectory (and then `blocks`) onto whichever
# base it resolved, file first or `-datadir` (`src/common/
# args.cpp:298-319`, same sha). `BlockDB.__init__` is where the
# `blocks` leaf itself, and the `data_dir` fallback, are appended --
# not here, so a caller building a `BlockDB` directly still gets
# Core's own default without going through `Config` at all.
blocks_dir: Path | None
# `None` and not an int is the whole of what `allow_p2p=False` and
# `allow_rpc=False` do: __init__ below leaves the port unset, and
# Node reads it as the answer to whether that listener is started
# at all. Declared `int` these two said the opposite of what they
# hold, and every reader believing the annotation would take a
# disallowed port for a port to bind.
p2p_port: int | None
rpc_port: int | None
# what RpcManager binds instead of every interface: an RPC server is
# this node's control plane, not a peer-to-peer listener, and
# rpc/callbacks.py carries no authentication of its own -- so the
# interface it is reachable from is the one thing between an
# unauthenticated caller and the network. Bitcoin Core's own
# `rpcbind`/`rpcallowip` default to localhost for the same reason;
# P2pManager.server binds every interface unconditionally, and is
# right to, since a peer listener is supposed to accept a stranger.
rpc_host: str
# `True` is Core's own `IsPruneMode()`: some block and undo data may
# be deleted, `MIN_BLOCKS_TO_KEEP` (constants.py, 288, Core's own two
# days) behind the tip never among it -- `block_db.BlockDB.prune_up_to`
# is what actually deletes. `prune_target_mib` below is the other
# half of Core's own `-prune=<n>`: which of manual (RPC-only) or
# automatic-to-a-MiB-target pruning this is.
pruned: bool
# `None` is Core's own manual pruning (`-prune=1`): nothing is
# deleted on its own, only `rpc.callbacks.prune_blockchain` deletes,
# and only when asked. An int is Core's own automatic pruning
# (`-prune=<n>`, `n >= MIN_PRUNE_TARGET_MIB`): `main._prune_chain`
# deletes on its own, keeping actual bytes under `blocks/` close to
# this many MiB, `MIN_BLOCKS_TO_KEEP` behind the tip still never
# reached. Read only where `pruned` is `True`; `main._prune_chain`
# and `rpc.callbacks.get_blockchain_info` both check `pruned` first.
prune_target_mib: int | None
debug: bool
min_relay_feerate: FeeRate
# (ip, port) pairs, resolved by `_resolve_peers` above: Core's own
# `-connect`, which dials these alone and turns off DNS seeding and
# every automatically-drawn outbound connection
# (`InitParameterInteraction`, `src/init.cpp:814-819`, and
# `connOptions.m_use_addrman_outgoing = false`, `src/init.cpp:2337`,
# both at bitcoin/bitcoin@ca7162cde5). Empty for `-connect=0` too --
# Core's own "dial nobody, but still on the -connect arm" spelling
# (`connect.size() != 1 || connect[0] != "0"`, `src/init.cpp:2333`,
# same sha) -- which is why `connect_given` below, not this tuple's
# truthiness, is what `P2pManager` reads to decide the two above.
connect: tuple[tuple[str, int], ...]
# Whether `-connect` was named at all, `["0"]` included: Core's own
# `!args.GetArgs("-connect").empty()`, read off the raw sequence
# `__init__` below was given rather than off `connect` above, since
# the two disagree on exactly that one value.
connect_given: bool
# the same pairs, dialled alongside the ordinary draw rather than
# instead of it: Core's own `-addnode`
# (`connOptions.m_added_nodes`, `src/init.cpp:2193-2198`, same sha).
addnode: tuple[tuple[str, int], ...]
# Core's own `-listen`, `DEFAULT_LISTEN` (`src/net.h`) true unless
# `-connect` is given, in which case `InitParameterInteraction`
# (`src/init.cpp:814-819`, same sha) soft-sets it false -- a default
# `cli.py`'s own `_resolve_listen` computes the same way, an explicit
# `-listen`/`-nolisten` always winning over it. `False` here means
# Core's own `-listen=0`: no bound listening socket, outbound
# connections still made -- not `allow_p2p=False`, which unsets the
# port and starts no `P2pManager` at all, so nothing could dial out
# either.
listen: bool
# every parameter here is one independent setting, not a group of
# related ones this signature happens to expose together: `chain` is
# not `data_dir`'s business, `rpc_host` is not `debug`'s, and nesting
# them into sub-objects would only move each still-independent knob
# behind one more name for every caller, all of which already read
# this constructor by keyword (`grep -rn "Config(" tests/
# src/btclib_node/` finds no positional call). PLR0913/PLR0917 measure a
# count this object's whole purpose is to be flat, not a shape it
# backed into. Keyword-only throughout (issue #341's own FBT round)
# for the same reason: `allow_p2p`/`allow_rpc`/`pruned`/`debug` are
# what that round's own findings are, but every other parameter here
# is already keyword at every call site the grep above found, so
# making only the booleans keyword-only would leave the same
# constructor answering `Config("regtest")` for one parameter and
# refusing it for the next -- which also drops PLR0917 (too many
# positional arguments) below to zero, keyword-only meaning there
# is no longer a positional count to measure.
def __init__( # noqa: PLR0913
self,
*,
chain: Chain | str = DEFAULT_CHAIN,
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 = DEFAULT_MIN_RELAY_FEERATE,
connect: Sequence[str] = (),
addnode: Sequence[str] = (),
listen: bool = True,
) -> None:
"""Resolve `chain` and ports."""
self.chain = _resolve_chain(chain)
data_dir = Path(data_dir) if data_dir else Path.home() / ".btclib"
self.data_dir = data_dir.absolute() / self.chain.name
self.blocks_dir = None
if blocks_dir is not None:
# Core's own check, on the raw value, before the
# chain-specific subdirectory below is ever appended to it
# (`GetBlocksDirPath`, same citation as the field comment):
# "Specified blocks directory ... does not exist"
# (`src/init.cpp:1006`, same sha) is fatal there too, not a
# silent `mkdir` -- unlike `data_dir` above, which every
# caller here is content to have created on first use.
resolved = Path(blocks_dir).absolute()
if not resolved.is_dir():
err_msg = f"specified blocks directory {resolved} does not exist"
raise ValueError(err_msg)
self.blocks_dir = resolved / self.chain.name
self.connect_given = bool(connect)
# Core's own "-connect=0": still the -connect arm above, but
# nobody named to dial -- `_resolve_peers` never sees the "0"
# itself, since `ip_address("0")` is not a valid literal and
# would raise where Core instead special-cases the value.
self.connect = (
() if list(connect) == ["0"] else _resolve_peers(connect, self.chain.port)
)
self.addnode = _resolve_peers(addnode, self.chain.port)
self.listen = listen
self.p2p_port = None
if allow_p2p:
self.p2p_port = self.chain.port
if p2p_port:
self.p2p_port = p2p_port
self.rpc_port = None
if allow_rpc:
self.rpc_port = self.chain.rpc_port
if rpc_port:
self.rpc_port = rpc_port
self.rpc_host = rpc_host
self.pruned = pruned
self.prune_target_mib = prune_target_mib
self.debug = debug
self.log_path = log_path
self.min_relay_feerate = min_relay_feerate