# 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 btclib fetcher backed by an Electrum server.
`btclib.electrum` is the codec -- the JSON-RPC framing, the shapes of the
methods this fetcher asks, and the merkle-branch check -- and holds
no socket; this module is the `Fetcher` over it, taking a `transport` and
turning each question into a request line and a line back into a btclib
type, the way `EsploraFetcher` and `BitcoinCoreRestFetcher` do over their
own transports.
**`transport` is required, keyword-only, and has no default.**
`LineTransport` (`btclib_wallet.fetch.transport`) carries no host, so a
transport that reaches a server is one constructed with that server's
host -- `TlsLineTransport(host, port)` is the one shipped -- and a default
transport would be a default server, which the next paragraph refuses.
**No `base_url` and no default server.** Unlike `EsploraFetcher`, which
takes a required url with no default, this fetcher takes none at all:
`transport` is the whole of how it reaches a server, and which server
that is is `transport`'s own business. `EsploraFetcher.base_url`'s
reasoning -- `BLOCKSTREAM_INFO` is offered as a value to pass and never
as a default, because which host sees every address a caller looks up is
not btclib's decision to make on anyone's behalf -- applies here with
even less room. Electrum's own shipped server list carries each host's
ports, its pruning and the protocol version it speaks, and nothing about
the certificate it presents, so which of its entries a transport
verifying against a public CA store reaches is not a question the list
answers. Electrum's own client does not ask it either: where the
CA-signed handshake fails it pins the certificate that server presented
and connects with `check_hostname` off (`electrum/interface.py`,
`Interface._get_ssl_context`), so being listed says what protocol
version a server speaks and not that its certificate is one a strict
transport accepts. Entries share registrable domains, so the list names
fewer operators than hosts, and a caller running their own server is
invisible to it entirely. Naming any one entry as a constant would
repeat the mistake `BLOCKSTREAM_INFO` already refuses.
**A question the interface does not declare, answered by nothing else in
this package.** `get_tx_merkle` and `verify_tx` are not on `Fetcher`:
adding a return type no other backend can honor is what the interface's
abstract methods, each with a return type of its own, already exist to
avoid, and both the issue and issue btclib-org/btclib#1193 hold the interface
itself unchanged. What `Fetcher`'s own class docstring calls "evidence beside
the data" is what these two are -- a merkle branch checked against a header this
fetcher fetched on its own, which is a different kind of answer from the other
backends' word for it, and the reason this backend exists.
"""
from __future__ import annotations
from btclib import electrum
from btclib.alias import Octets
from btclib.block.block_header import BlockHeader
from btclib.exceptions import BTClibValueError, FetchError
from btclib.fee import FeeRate
from btclib.network import NETWORKS
from btclib.tx import Tx
from typing_extensions import override
from btclib_wallet.fetch.fee_estimator import FeeQuote, valid_confirmation_target
from btclib_wallet.fetch.fetcher import (
NetworkVerifyingFetcher,
block_header_from_raw,
block_header_height,
client_errors,
fetch_errors,
tx_from_raw,
tx_id_hex,
)
from btclib_wallet.fetch.transport import DEFAULT_TIMEOUT, LineTransport
__all__ = [
"ElectrumFetcher",
]
[docs]
class ElectrumFetcher(NetworkVerifyingFetcher):
"""Every `Fetcher` question, and a merkle branch, from an Electrum server.
`get_tx` is `blockchain.transaction.get`, the raw hex decoded and
handed to `tx_from_raw`, which recomputes the id the same way every
other backend does. `get_block_count` and `get_best_block_id` are
both `blockchain.headers.subscribe`, asked once each: the protocol
answers the tip's height together with its header rather than a
separate hash field, so the id `get_best_block_id` returns is not the
server's word -- it is `BlockHeader.hash` of the header
`block_header_from_raw` has already checked is well-formed and cost
real work to find, the same check `get_block_header` runs.
`get_block_header` is `blockchain.block.header`, `cp_height` left
unsent.
`get_tx_merkle` is `blockchain.transaction.get_merkle`, returning the
branch and position `btclib.electrum.MerkleProof` carries.
`verify_tx` fetches the header at the height asked for with
`get_block_header` -- the caller's own height, not the proof's
unchecked `block_height` -- and checks the branch against it with
`btclib.electrum.verify_merkle_proof`, answering `False` for a
malformed branch or a wrong position the way
`btclib.block.merkle_proof.verify` does, and still raising for
anything `get_block_header` itself refuses.
Also a `FeeEstimator`: `estimate_fee` is `blockchain.estimatefee`,
answered for the target asked -- the protocol carries no field
equivalent to Core's `blocks`, so unlike the other two backends this
one cannot report a clamp the server may have applied underneath it.
`get_tx_out` is not overridden, and stays the `Fetcher` base's
derivation from `get_tx`. The protocol answers a script hash's
history and its unspent outputs, `blockchain.scripthash.get_history`
and `.listunspent`, but `ElectrumFetcher` does not ask either
question: every `Fetcher` question is keyed on an identifier the
caller already holds -- a txid, a height, nothing -- and a script is
not one.
"""
def __init__(
self,
network: str = "mainnet",
*,
transport: LineTransport,
verify_network: bool = True,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
super().__init__(network, verify_network=verify_network)
self.transport = transport
self.timeout = timeout
self._next_id = 0
def _next_request_id(self) -> int:
"""Return a fresh request id, one higher than the last.
A counter and not a constant: `decode_response` refuses a reply
that answers a different id, so two requests sharing one id could
not be told apart by anything this codec checks.
"""
self._next_id += 1
return self._next_id
def _round_trip(self, request: bytes) -> bytes:
"""Send one request line and return the line answering it.
The one call site that reaches `self.transport`, so a translation
of what it raises is written once. `LineTransport`'s own
docstring is where the exception contract this wraps is stated.
"""
with client_errors():
return self.transport(request, self.timeout)
def _tip(self) -> electrum.HeaderTip:
"""Return the chain tip `blockchain.headers.subscribe` answers."""
request_id = self._next_request_id()
line = self._round_trip(electrum.headers_subscribe_request(request_id))
with fetch_errors("headers.subscribe"):
return electrum.headers_subscribe_response(line, request_id)
def _header_at(self, height: int) -> BlockHeader:
"""Return the header at this height, checked, asking nothing else.
The seam `get_block_header` and `assert_network` share, the way
`_tip` is the one `get_block_count` and `get_best_block_id`
share: `assert_network` reaches the genesis header through this
and not through `get_block_header`, which calls `_verify_once`.
`block_header_from_raw` is what checks the eighty bytes, so the
header a caller compares or hashes is one that is well-formed and
cost real work to find, and not the server's word for either.
"""
request_id = self._next_request_id()
line = self._round_trip(electrum.block_header_request(request_id, height))
with fetch_errors(f"block header {height}"):
raw = electrum.block_header_response(line, request_id)
return block_header_from_raw(raw, height)
[docs]
@override
def get_tx(self, tx_id: Octets) -> Tx:
"""Return the transaction with this id, via `transaction.get`."""
self._verify_once()
hex_ = tx_id_hex(tx_id)
request_id = self._next_request_id()
line = self._round_trip(electrum.transaction_get_request(request_id, hex_))
with fetch_errors(f"transaction {hex_}"):
raw = electrum.transaction_get_response(line, request_id)
return tx_from_raw(raw, hex_, self.network)
[docs]
@override
def get_block_count(self) -> int:
"""Return the height of the chain tip, via `headers.subscribe`."""
self._verify_once()
return self._tip().height
[docs]
@override
def get_best_block_id(self) -> bytes:
"""Return the id of the chain tip, recomputed from its own header.
`headers.subscribe` answers the header itself rather than a
separate hash field, so this is not the server's word: it is
`block_header_from_raw`'s check on the bytes it sent, and then
the hash of the header that passed it.
"""
self._verify_once()
tip = self._tip()
header = block_header_from_raw(tip.header, tip.height)
return header.hash
[docs]
@override
def assert_network(self) -> None:
"""Raise unless the server serves the chain this fetcher labels with.
One request and one comparison: `blockchain.block.header` at
height 0 -- the same method `get_block_header` asks for every
other height, asked here for the block every chain starts from --
against `NETWORKS[self.network].genesis_block`. What is compared
is `BlockHeader.hash` of the eighty bytes `_header_at` has
checked, so this backend answers the question the way it answers
`get_best_block_id`: by hashing a header rather than by reading a
hash the server chose. `server.features` carries a `genesis_hash`
member and is what Electrum's own client compares
(`electrum/interface.py`); the header is asked for instead
because it makes the server produce eighty bytes that hash to the
genesis rather than repeat a string, and because it needs no
codec function `btclib.electrum` does not already have.
Worth the call, because the failure it catches is silent, the
same one `BitcoinCoreFetcher.assert_network`'s docstring names: a
fetcher labelled `mainnet` over a server on another chain renders
a mainnet address for every output it fetches, for coins that are
not there. It is sharper here than elsewhere, because `verify_tx`
checks a branch against a header fetched from that same server:
a caller on the wrong chain is otherwise handed a proof that is
valid and about a chain they did not mean.
What a genesis hash cannot separate is two signets, and
`EsploraFetcher.assert_network`'s docstring is where that is
written down. This class takes no `signet_challenge` of its own,
for the reason that class takes none: nothing among the methods
`btclib.electrum` speaks is a signet's challenge to compare
against.
"""
reported = self._header_at(0).hash
expected = NETWORKS[self.network].genesis_block
if reported != expected:
err_msg = "the server serves a chain whose genesis is"
err_msg += f" {reported.hex()}, not the {expected.hex()}"
err_msg += f" this {self.network} fetcher was built for"
raise BTClibValueError(err_msg)
[docs]
def get_tx_merkle(self, tx_id: Octets, height: int) -> electrum.MerkleProof:
"""Return the branch proving `tx_id` confirmed at `height`.
`blockchain.transaction.get_merkle`, the one question no other
backend here can answer at all.
"""
self._verify_once()
hex_ = tx_id_hex(tx_id)
height = block_header_height(height)
request_id = self._next_request_id()
line = self._round_trip(
electrum.transaction_get_merkle_request(request_id, hex_, height)
)
with fetch_errors(f"transaction {hex_} merkle"):
return electrum.transaction_get_merkle_response(line, request_id)
[docs]
def verify_tx(self, tx_id: Octets, height: int) -> bool:
"""Return whether `tx_id` is proven confirmed at `height`.
Fetches the header at `height` and the branch separately, then
checks the second against the first's merkle root -- see this
class's own docstring for what each half already checks on its
own.
"""
header = self.get_block_header(height)
proof = self.get_tx_merkle(tx_id, height)
return electrum.verify_merkle_proof(tx_id, proof, header)
[docs]
def estimate_fee(self, target: int) -> FeeQuote:
"""Return a fee rate expected to confirm within `target` blocks.
`blockchain.estimatefee`. `-1`, the protocol's own way of saying
no estimate is available, is a decline and raised as a
`FetchError` rather than reaching `FeeRate` -- no sentinel
reaches the return type, the way `Broadcaster`'s third contract
bullet already requires of a refusal. Servers commonly proxy this
answer from a node's own `estimatesmartfee`, so it is read in the
same BTC/kvB unit `FeeRate.from_btc_per_kvbyte` already converts.
"""
self._verify_once()
target = valid_confirmation_target(target)
request_id = self._next_request_id()
line = self._round_trip(electrum.estimate_fee_request(request_id, target))
with fetch_errors("blockchain.estimatefee"):
result = electrum.estimate_fee_response(line, request_id)
if result == -1:
err_msg = f"blockchain.estimatefee: no estimate for target {target}"
raise FetchError(err_msg)
rate = FeeRate.from_btc_per_kvbyte(result, round_up=True)
return FeeQuote(rate=rate, target=target)