# 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.
"""The interface a chain backend answers, whichever backend it is.
The questions btclib cannot answer from bytes it was handed: what
transaction has this id, what output does this outpoint name, where is
the chain tip, and what header does a given height carry. `Fetcher` is
those questions and nothing else, so that calling code takes a `Fetcher`
and never learns whether a full node or an explorer is behind it.
What comes back is btclib types -- `Tx`, `TxOut`, `BlockHeader` -- and
not the dicts the backends send. A wrapper handing over
`response["vout"][0]["value"]` leaves the caller to know which backend it
is talking to, in the one place the whole point was not to.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Iterator
from contextlib import contextmanager
import bitcoin_core_rpc as rpc
from btclib.alias import Octets
from btclib.block.block_header import BlockHeader
from btclib.exceptions import (
BTClibRuntimeError,
BTClibTypeError,
BTClibValueError,
FetchError,
HttpError,
RpcError,
)
from btclib.network import NETWORKS, network_from_name, validated_network_name
from btclib.script import ScriptPubKey
from btclib.tx import OutPoint, Tx, TxOut
from btclib.utils import bytes_from_octets, is_integer, is_octets
__all__ = [
"Fetcher",
"NetworkVerifyingFetcher",
"block_header_from_raw",
"block_header_height",
"client_errors",
"fetch_errors",
"tx_for_network",
"tx_from_raw",
"tx_id_hex",
]
[docs]
@contextmanager
def client_errors() -> Iterator[None]:
"""Re-raise what the rpc client raises as btclib's own exception.
`bitcoin_core_rpc` declares a `FetchError`, an `HttpError` and an
`RpcError` of its own -- it declares zero dependencies and imports
nothing of btclib's -- so those are not the classes
`btclib.exceptions` declares, and an `except FetchError` written
against btclib does not catch them. This is the one place the
two meet, and every call that crosses into the package goes through
it: the fetches and the broadcasts alike, by way of
`EsploraFetcher._request` -- `text` and `broadcast` both going
through it -- `BitcoinCoreFetcher._call`, which `broadcast` uses the
same as every other method there, and
`BitcoinCoreRestFetcher._get_bin`/`_get_json`; the constructors that
derive a signet challenge before any fetch; and
`BitcoinCoreFetcher.assert_network`, which reaches `assert_chain`
directly where its `-rest` twin asks the same question through
`_get_json` and is covered by the first group.
`ElectrumFetcher._round_trip` is the same wrap over a caller-supplied
`transport` rather than a call into this package directly: a
`LineTransport` that cannot answer raises this same vocabulary, its
own docstring says so, and this is where that translation happens
for every one of that fetcher's calls.
The fields are what make this a translation rather than a blanket
wrap: `status` and `code` are the whole reason those two classes
exist, and losing them would leave a caller matching on the text of a
message again.
`args[0]` and not `str(e)`: both sides compose their message in
`__str__`, so handing the composed one back in would report
"not found (rpc error code -5) (rpc error code -5)" -- once more per
translation.
"""
try:
yield
except rpc.RpcError as e:
raise RpcError(e.args[0], e.code, e.data) from e
except rpc.HttpError as e:
raise HttpError(e.args[0], e.status) from e
except rpc.FetchError as e:
raise FetchError(str(e)) from e
except rpc.BtcRpcValueError as e:
# what `assert_chain` raises for a node serving another chain: a
# refusal on valid inputs, so `BTClibValueError` and not a
# `FetchError`. Its message names the node's chain and the client's,
# which is the whole of what a caller has to act on
raise BTClibValueError(str(e)) from e
[docs]
@contextmanager
def fetch_errors(source: str) -> Iterator[None]:
"""Report what a conversion refuses as a failure of `source`.
Every answer a backend gives is a string it chose, so every parse of
one is a place the backend can be wrong: a hex field that is not hex,
a height that is not a number, a transaction truncated in transit.
Those arrive as ValueError and TypeError from `int` and
`bytes_from_octets`, and as BTClibRuntimeError from the stream
readers under `Tx.parse` -- "not enough binary data" is what a
transaction truncated in transit looks like from inside `var_bytes`.
All of them name the converter and not the host, and the host is what
has to be fixed.
"""
try:
yield
except FetchError:
# already the answer this would produce, and a BTClibRuntimeError
# too, so it has to be let through before the clause below wraps
# its message inside a second copy of itself
raise
except (TypeError, ValueError, BTClibRuntimeError) as e:
raise FetchError(f"{source}: {e}") from e
[docs]
def tx_for_network(tx: Tx, network: str) -> Tx:
"""Return the transaction with its outputs labelled for `network`.
`Tx.parse` labels every `script_pub_key` mainnet, and is right to:
the serialization carries a script and no network, so a parser handed
bytes alone has nothing else to say. A fetcher does -- it was told
which chain it is talking to -- and the label is what
`ScriptPubKey.address` renders from, so an unlabelled testnet output
reports a mainnet address for coins that are not there.
Mainnet in, mainnet out: for the default network this returns a
transaction equal to its argument, the label being the only thing it
touches. The bytes are untouched in every case, `ScriptPubKey`
serializing the script alone.
The name is resolved and not compared as text. Resolving refuses a
network no table has, which every `check_validity=False` below would
otherwise write into the transaction handed back, to surface far
from here as whatever went on to render an address; and it answers
the same for " MainNet " as for "mainnet", where a comparison would
relabel every output instead of returning the transaction as it is.
"""
if network_from_name(network) == NETWORKS["mainnet"]:
return tx
vout = [
TxOut(
out.value,
ScriptPubKey(out.script_pub_key.script, network, check_validity=False),
check_validity=False,
)
for out in tx.vout
]
return Tx(tx.version, tx.lock_time, tx.vin, vout, check_validity=False)
[docs]
class Fetcher(ABC):
"""What a backend must answer, and what btclib does with the answers.
One abstract method per question, each with a return type of its
own. That is the shape on purpose rather than one `get(kind, id)`
returning whatever: a backend able to *prove* what it says -- the
Electrum protocol serves a merkle branch, which a client checks
against a header it already holds (issues btclib-org/btclib#188,
btclib-org/btclib#204 and btclib-org/btclib#1132) -- returns evidence beside
the data, and evidence is a different type. Adding a method for it is
additive; widening the return type of `get_tx` would be a break for everyone
already calling it.
`get_tx_out` is concrete, and is the one operation every backend can
derive from another: an output is a field of the transaction that
created it. A backend with a cheaper answer overrides it.
"""
network: str
def __init__(self, network: str = "mainnet") -> None:
"""Bind the fetcher to a network, by the name btclib resolves.
`validated_network_name` is the converter `descriptors.parse`
and `p2p.magic.magic_from_network` reach for across modules, so
the `strip().lower()` tolerance issue btclib-org/btclib#216 decided to
keep reaches a backend too. `self.network` is a key of `NETWORKS`, and
it is what `tx_from_raw` labels a transaction with.
"""
self.network = validated_network_name(network)
[docs]
@abstractmethod
def get_tx(self, tx_id: Octets) -> Tx:
"""Return the transaction with this id."""
[docs]
@abstractmethod
def get_block_count(self) -> int:
"""Return the height of the chain tip."""
[docs]
@abstractmethod
def get_best_block_id(self) -> bytes:
"""Return the id of the block at the chain tip."""
[docs]
def get_tx_out(self, out_point: OutPoint) -> TxOut:
"""Return the output an outpoint names, spent or not.
Spent or not, which is what makes this the useful question and
not `gettxout`'s. bitcoind's `gettxout` reads the utxo set, so it
answers null for an output that has been spent -- and every input
of a confirmed transaction names an output that has been spent,
by that very transaction. A fee is the inputs less the outputs,
so an unspent-only answer cannot compute one.
The cost is that the whole previous transaction is fetched to
read one output of it, and against bitcoind that means a node
with `-txindex`.
"""
tx = self.get_tx(out_point.tx_id)
if out_point.vout >= len(tx.vout):
err_msg = f"out of range vout: {out_point.vout}"
err_msg += f" for a transaction with {len(tx.vout)} outputs"
raise FetchError(err_msg)
return tx.vout[out_point.vout]
[docs]
class NetworkVerifyingFetcher(Fetcher):
"""A `Fetcher` that can ask its host which chain it serves.
Every backend reaching a host over a connection is one of these:
`network` is a label the caller chose, a host on another chain
answers every question just as readily, and asking is the only way
to tell. `CachingFetcher` and `FallbackFetcher` are `Fetcher`s and
not these -- each answers from another `Fetcher`, which is where the
host is and where the question belongs -- which is why the check is
declared here rather than on `Fetcher`, where those two would carry
a `verify_network` with nothing to ask.
`verify_network` is who asks. On by default and before the first
fetch rather than in the constructor: the answer costs a round trip
that is worth paying where it is checked and wasted where a fetcher
is built and never used, and a host that is merely unreachable
should not be a failure to *construct* anything. The answer is then
kept -- a host does not change chain under a client that goes on
pointing at it -- and a caller that would rather not ask says
`verify_network=False`.
"""
verify_network: bool
def __init__(
self, network: str = "mainnet", *, verify_network: bool = True
) -> None:
"""Bind the fetcher to a network, and to whether its host is asked."""
super().__init__(network)
self.verify_network = verify_network
self._agreed = False
self._disagreement = ""
[docs]
@abstractmethod
def assert_network(self) -> None:
"""Raise unless the host serves the chain this fetcher labels with.
What settles it is the backend's: a chain name and a signet
challenge where the host is a node reporting both, a genesis
block where it is a host that only says it validated. Abstract
rather than a default, which could only pass: a backend
declaring none would check the chain never and compile.
Public, for a caller that wants the question answered at a moment
of its own -- at startup, or after a client was repointed.
A malformed reply is a `FetchError`. A disagreement is a
`BTClibValueError`: the host is the authority on which chain it
serves, so the fetcher's label is the thing to fix.
"""
def _verify_once(self) -> None:
"""Compare the host's chain with this fetcher's label, once.
Called by every question a backend answers, and by nothing
`assert_network` itself goes through -- a check there would ask
the host about the host, from inside the question.
A disagreement is remembered and raised again for every later
call, because it is a settled fact about a configuration rather
than a request that failed -- and a fetcher that asked once, was
refused once and then served an address would be the silent
failure this exists to stop. A `FetchError` is not an answer and
is remembered as nothing: the host was unreachable or spoke
nonsense, which the next call may well find otherwise.
"""
if not self.verify_network or self._agreed:
return
if self._disagreement:
raise BTClibValueError(self._disagreement)
try:
self.assert_network()
except BTClibValueError as e:
self._disagreement = str(e)
raise
self._agreed = True
[docs]
def tx_id_hex(tx_id: Octets) -> str:
"""Return the display hex of a transaction id, checking it is one.
Every backend puts the id in a request as hex, and each accepts
whatever `Octets` accepts, so each needs the same 32-byte check --
performed here rather than left to the backend, which would otherwise
report a mistyped id as the remote host's 404.
"""
return bytes_from_octets(tx_id, 32).hex()
[docs]
def tx_from_raw(raw: Octets, tx_id: str, network: str) -> Tx:
"""Return the transaction a serialization holds, if it is the one asked for.
Every backend answers `get_tx` with the serialization rather than with
a rendering of it, and this is why: the id is a hash of those bytes,
so recomputing it says whether what arrived is what was asked for. A
height is taken on the backend's word and a block header answers for
itself, which `SECURITY.md` states per answer rather than here; this
one costs a hash of a few hundred bytes.
It is not only the untrusted backend it guards. A node behind a
caching proxy, a truncated response and a request that raced another
all show up here, as the wrong id rather than as a wrong amount
somewhere later.
"""
if not is_octets(raw):
# `Tx.parse` reads a stream and lets anything that is not Octets
# through untouched, so a json number where the hex belongs
# surfaces as AttributeError on `.read` -- a traceback into the
# parser for what is the backend answering the wrong shape
err_msg = f"transaction {tx_id}: not a serialization," # type: ignore[unreachable]
err_msg += f" but a {type(raw).__name__}"
raise FetchError(err_msg)
with fetch_errors(f"transaction {tx_id}"):
tx = tx_for_network(Tx.parse(raw), network)
if tx.id.hex() != tx_id:
raise FetchError(f"transaction {tx_id}: the answer is {tx.id.hex()}")
return tx