Source code for btclib_wallet.fetch.fee_estimator

# 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 to quote a fee rate.

Kept apart from `Fetcher`, the way `Broadcaster` is: not every backend
that answers `Fetcher`'s questions can quote a price.
`BitcoinCoreRestFetcher`, over Core's read-only `-rest` interface, is
exactly that backend again -- Core's `-rest` interface carries no fee
estimation at all, `estimatesmartfee` being RPC only. A `Protocol` lets
`BitcoinCoreFetcher`, `ElectrumFetcher` and `EsploraFetcher` satisfy
`FeeEstimator` structurally, with nothing to say about
`BitcoinCoreRestFetcher` at all -- the same reasoning `broadcaster.py`'s
docstring gives for that class, one capability over.

Not `runtime_checkable`, for the reason `Broadcaster` is not: the
contract below is not what `isinstance(x, FeeEstimator)` would verify.

**One target asked, one answer -- not a mapping.** Core and Electrum
answer one confirmation target per request, so a mapping would be
synthesized for two backends out of three; Esplora's own targets are a
fixed set rather than a general shape, so a mapping is not the answer
there either.

**The target answered for need not be the target asked for, so the
return is not a bare `FeeRate`.** Core's `blocks` field is documented as
the target the estimate was found at, clamped to at least 2 and at most
the estimator's own maximum usable target -- asking for 1 and being
answered for 2 is the ordinary case, and a bare rate handed back as
though it answered the question asked would misreport it. `FeeQuote`
carries both.

This module sits above `fee.py`: it may import `FeeRate` from there, and
`fee.py` imports nothing from here, which is also the direction issue
btclib-org/btclib#2129 cuts the two into different distributions.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol

from btclib.exceptions import BTClibTypeError, BTClibValueError
from btclib.fee import FeeRate
from btclib.utils import is_integer

__all__ = ["FeeEstimator", "FeeQuote", "valid_confirmation_target"]


[docs] @dataclass(frozen=True) class FeeQuote: """A fee rate, and the confirmation target it is valid for. `target` is not necessarily the target `FeeEstimator.estimate_fee` was asked for: a backend may answer for a target it clamped or rounded to, and this is where that answer is carried rather than lost by returning `rate` alone. """ rate: FeeRate target: int
[docs] def valid_confirmation_target(target: int) -> int: """Return `target`, refusing what no backend answers a fee for. Checked once, ahead of the request, the way `fetcher.block_header_height` checks a height before the first request that needs one: a target that is not a positive int is refused here rather than left to surface as whichever error the backend happens to answer for it. """ if not is_integer(target): err_msg = f"invalid confirmation target type: {type(target).__name__}" raise BTClibTypeError(err_msg) if target < 1: raise BTClibValueError(f"invalid confirmation target: {target}") return target
[docs] class FeeEstimator(Protocol): """A backend able to quote a fee rate for a confirmation target. One method and one contract, binding on every implementation of it: - the target answered for need not be the target asked for, and the `FeeQuote` returned carries the one the rate is actually valid for; - a quote a backend expresses more finely than `FeeRate` can hold exactly is rounded up, never down -- `FeeRate.from_sats_per_vbyte` and `FeeRate.from_btc_per_kvbyte` take `round_up=True` here, rather than the refusal each raises by default for a caller who is *stating* a price: refusing an ordinary explorer answer would make the backend unusable, and truncating would under-pay. Rounding a conservative estimate up leaves it an estimate; - a backend that cannot quote a rate for the target -- Core's `feerate` absent from a reply that carries `errors` instead, Electrum's `-1` -- declines rather than answering, and is raised as a `FetchError` keeping the backend's own reason, unreshaped, the way `Broadcaster`'s third contract bullet already requires of a refusal. No sentinel reaches `FeeQuote`. """
[docs] def estimate_fee(self, target: int) -> FeeQuote: """Return a fee rate expected to confirm within `target` blocks.""" ...