Source code for btclib_wallet.core_import

# 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 requests Bitcoin Core's `importdescriptors` takes.

https://github.com/bitcoin/bitcoin/blob/master/src/wallet/rpc/backup.cpp

What a wallet does with the descriptors `descriptors.account_descriptors`
builds: hand them to a node, which then watches every script they
describe. A request is one json object per descriptor, and this module is
the object -- no rpc call and no client, deliberately. The caller already
has one, `bitcoin-core-rpc` being a package of its own that
`btclib_wallet.fetch.bitcoin_core` builds on, and a second way to reach a node
would be a second thing to keep working.

`import_request` is one object; `account_import_requests` is the pair a
BIP44 account is, the receiving chain and the change chain marked
`internal`. That mark is why the pair is a function of its own: change
imported as a receiving chain is money the wallet reports as incoming
payments, and the mistake is invisible until a balance is wrong.

Every rule Core enforces on a request is enforced here, where the error
can still say which field it was rather than arriving as an rpc failure
half a rescan later:

- the descriptor carries its checksum, `Parse` being called there with
  ``require_checksum = true``;
- an `active` descriptor is ranged, an unranged one having no keypool to
  be the active source of;
- a `range` belongs to a ranged descriptor and to no other;
- both ends of a `range` are what `ParseRange` and `ParseDescriptorRange`
  in `src/rpc/util.cpp` take: ordered, non-negative, an end below 2**31,
  and fewer than a million indexes between them;
- `next_index` is inside that range;
- a `label` goes with neither `internal` nor a range;
- a `timestamp` is a number or the exact string `now`, which is what
  `GetImportTimestamp` accepts and nothing else -- `"NOW"` is not it.

Two things are not written, where HWI's `getkeypool` writes them:
`watchonly` and `keypool` are `importmulti` fields -- the other rpc that
dict targets -- and `importdescriptors` defines neither. A descriptor
wallet is watch-only by holding no private key, which `descriptors.parse`
guarantees of every descriptor it returns.

A multipath descriptor is not built here either: Core takes one and reads
the second element of a two-element step as the internal descriptor, while
`descriptors.parse` refuses a ``<a;b>`` step outright and
`multipath_descriptors` is what expands one. So the pair of requests is
what this module has, and it says the same thing in two objects.

Two of Core's answers are read here too, for the reason the requests are
built here: both are knowledge of how the node behaves rather than of the
protocol, and neither needs a node to be reached, being a function of a
reply the caller already has. `assert_imported` reads what
`importdescriptors` answered, which reports a refusal inside the reply
instead of failing the call. `watched_range` reads what `listdescriptors`
answered, and `widened_range` turns it into the range a second import may
ask for: Core widens any ranged import to its keypool and then refuses
every later one that would narrow what it widened to, so importing the
same descriptor twice is idempotent only for a caller that asks for at
least what is there already.
"""

from __future__ import annotations

# imported at runtime and not under TYPE_CHECKING, which the annotations
# below would not need and the documentation build does: with the names
# missing from the module, `get_type_hints` fails on the signatures using
# them and autodoc falls back to the text of the annotation, where
# `Descriptor` is a cross-reference with two targets -- one sphinx warning,
# which `lint.yml` runs with -W
from collections.abc import Mapping, Sequence
from typing import Any

from btclib.exceptions import BTClibRuntimeError, BTClibValueError
from btclib.utils import assert_type, is_integer

from btclib_wallet.descriptors import Descriptor, add_checksum, parse

__all__ = [
    "DEFAULT_RANGE",
    "NOW",
    "account_import_requests",
    "assert_imported",
    "import_request",
    "watched_range",
    "widened_range",
]

# what Core substitutes the current chain time for, and what a caller
# means by "these scripts were never used": the string bypasses the
# rescan, where a timestamp of 0 scans the whole chain
NOW = "now"

# Core's own default keypool size, which is what it falls back to for a
# ranged descriptor imported without a range. Written out rather than left
# out: a request with no range makes Core answer "Range not given, using
# default keypool range" as a warning, and a caller reading that back
# cannot tell which indexes were imported
DEFAULT_RANGE = (0, 999)

# `high >= low + 1000000` is "Range is too large" in ParseDescriptorRange,
# so a million indexes is what a range may hold and 999_999 the widest
# gap between its inclusive ends
_MAX_RANGE_SPAN = 1_000_000


def _is_ranged(descriptor: Descriptor | str) -> bool:
    """Answer whether the descriptor describes a range of scripts.

    A `Descriptor` answers for itself; text is parsed for the answer, and
    that parse is also what refuses text which is no descriptor at all --
    a request built around it would be refused by the node instead, which
    is a rescan away.
    """
    if isinstance(descriptor, Descriptor):
        return descriptor.is_ranged
    return parse(descriptor).is_ranged


def _assert_timestamp(timestamp: int | str) -> None:
    """Refuse a timestamp Core's GetImportTimestamp would refuse.

    A number, or the string `now` and no other string: Core compares the
    text against "now" and answers `Expected number or "now" timestamp
    value for key` to everything else, so `"NOW"` and `"yesterday"` fail
    the whole request. A refusal here names the field, where the rpc
    error names the key it was importing.

    `is_integer` rather than `isinstance(timestamp, int)`, which is the
    predicate every integer field of the library is held to: `True` is an
    `int` to Python and a boolean to json, and Core reads a json boolean
    as neither a number nor a string.
    """
    if is_integer(timestamp):
        return
    if timestamp == NOW:
        return
    err_msg = f'expected a number or "{NOW}" as timestamp: {timestamp!r}'
    raise BTClibValueError(err_msg)


def _assert_no_label(label: str, why: str) -> None:
    """Refuse a label where Core refuses one, naming which rule it was."""
    if label:
        raise BTClibValueError(f"a {why} descriptor takes no label")


def _assert_key_range(start: int, end: int) -> None:
    """Refuse a range Core's ParseDescriptorRange would refuse.

    Both ends are inclusive, which is how Core reads them -- it adds one to
    the second itself -- so an end below its start is no range at all.

    The two bounds are `ParseDescriptorRange`'s, which `importdescriptors`
    calls on this field: an end of 2**31 or above is "End of range is too
    high", and a span of a million indexes or more is "Range is too
    large". Neither is a shape a keypool has any use for -- Core's own
    default is a thousand -- so what they catch is an argument computed
    wrongly, and catching it here is the difference between naming the
    field and reading an rpc failure after a rescan.

    A rule of Core's that is deliberately not repeated: every numeric
    field arrives through `UniValue::getInt<int64_t>`, which refuses what
    does not fit. After the bounds above, `start` and `end` are inside
    [0, 2**31), so nothing here can reach it.
    """
    if not 0 <= start <= end:
        raise BTClibValueError(f"invalid range: [{start}, {end}]")
    if end >> 31:
        raise BTClibValueError(f"end of range is too high: {end}")
    if end - start >= _MAX_RANGE_SPAN:
        err_msg = f"range is too large: {end - start + 1} indexes, "
        err_msg += f"max is {_MAX_RANGE_SPAN}"
        raise BTClibValueError(err_msg)


def _range_fields(
    key_range: tuple[int, int] | None, next_index: int | None
) -> dict[str, Any]:
    """Return the range fields of a request, checked against each other.

    The range is `_assert_key_range`'s, and `next_index` is where the
    wallet carries on from, so it is inside the range or it is nothing --
    which is the one rule of the two fields that is about the pair rather
    than about either.
    """
    if key_range is None:
        if next_index is not None:
            raise BTClibValueError("next_index without a range to be inside of")
        return {}

    start, end = key_range
    _assert_key_range(start, end)
    fields: dict[str, Any] = {"range": [start, end]}
    if next_index is not None:
        if not start <= next_index <= end:
            err_msg = f"next_index {next_index} is not in [{start}, {end}]"
            raise BTClibValueError(err_msg)
        fields["next_index"] = next_index
    return fields


[docs] def import_request( descriptor: Descriptor | str, timestamp: int | str = NOW, *, internal: bool = False, active: bool = True, key_range: tuple[int, int] | None = DEFAULT_RANGE, next_index: int | None = None, label: str = "", ) -> dict[str, Any]: """Return the `importdescriptors` request for one descriptor. `descriptor` is a `Descriptor` or the text of one; either way what the request carries is the checksummed text, which is what Core requires of a descriptor it imports. `timestamp` is where the rescan starts, in Unix time, `NOW` being Core's own way of saying "do not rescan": right for a descriptor whose scripts have never been used, and wrong for one being restored, where the time of the wallet's first use is what finds its history. `NOW` is the default for the reason there is no default restore date -- this module cannot know one, and a silent 0 would rescan the whole chain. A number or `NOW` itself, and no other string: Core takes those two and refuses the rest, `"NOW"` included. `internal` marks the change chain, which Core then keeps out of what it reports as incoming payments. `active` makes the descriptor the wallet's source of new addresses for that output type and externality, which is what an import for spending wants and a bare watch of some scripts does not. Core requires an active descriptor to be ranged, so this does too. `key_range` is the inclusive pair Core takes, both ends included -- Core adds one to the second itself. None leaves the field out, which is what an unranged descriptor takes. Ordered, non-negative, an end below 2**31 and fewer than a million indexes wide, which are `ParseDescriptorRange`'s bounds. `next_index` is where an active ranged descriptor hands out its next address, and has to be inside the range, as Core checks. `label` names the address, and Core allows one only for a single unranged receiving descriptor: not for change, and not for a range. """ # both are fields of the request Core is handed, so a truth read for # its truth would import the other kind of descriptor assert_type(internal, bool, "internal") assert_type(active, bool, "active") text = add_checksum(descriptor if isinstance(descriptor, str) else str(descriptor)) is_ranged = _is_ranged(descriptor) _assert_timestamp(timestamp) if active and not is_ranged: err_msg = "an active descriptor must be ranged: one script is no keypool" raise BTClibValueError(err_msg) if key_range is not None and not is_ranged: raise BTClibValueError("an unranged descriptor takes no range") if internal: _assert_no_label(label, "change") if is_ranged: _assert_no_label(label, "ranged") request: dict[str, Any] = { "desc": text, "timestamp": timestamp, "internal": internal, "active": active, **_range_fields(key_range, next_index), } if label: request["label"] = label return request
[docs] def account_import_requests( receive: Descriptor, change: Descriptor, timestamp: int | str = NOW, *, active: bool = True, key_range: tuple[int, int] = DEFAULT_RANGE, ) -> list[dict[str, Any]]: """Return the two requests a BIP44 account is imported with. The pair `descriptors.account_descriptors` builds, with the second marked `internal`: that mark is what keeps a wallet from reporting its own change as incoming payments, and it is the one thing a caller writing the two requests by hand gets wrong. Both chains or neither: a wallet holding the receiving chain alone cannot recognize the change it makes itself, which is an output it stops seeing rather than a feature it lacks. """ return [ import_request( receive, timestamp, internal=False, active=active, key_range=key_range ), import_request( change, timestamp, internal=True, active=active, key_range=key_range ), ]
def _comparable(descriptor: Descriptor | str) -> str: """Return the text of a descriptor as one is compared to another. The expression without its checksum, every hardened step spelled `h`: `listdescriptors` echoes the checksum Core computed and the hardening symbol Core was given, and neither is a difference between two descriptors describing the same scripts. An expression this library wrote carries `h` already -- `str_from_index_int` writes the symbol Core's own `FormatHDKeypath` writes -- and one a wallet was imported with by another tool can carry apostrophes, which is the case this normalization is for: read as a descriptor never seen, it would be imported a second time over a range Core refuses to narrow. `partition` and not `strip_checksum`, which verifies what it strips: an entry of a wallet is any descriptor that wallet holds, including one whose checksum or charset this library would refuse, and such an entry is still not the descriptor being looked for -- a lookup that raises over it answers nothing about the one asked about. """ text = descriptor if isinstance(descriptor, str) else str(descriptor) expression, _, _checksum = text.partition("#") return expression.replace("'", "h")
[docs] def watched_range( descriptor: Descriptor | str, reply: Mapping[str, Any] ) -> tuple[int, int] | None: """Return the range of a descriptor a wallet watches, None for none. `reply` is what `listdescriptors` answered, which is a wallet's account of itself: one entry per descriptor it holds, echoing the expression it was imported with and the range it ended up with. This is the question a caller has to ask before importing a descriptor a second time, Core refusing an import that would narrow that range, and `widened_range` is what the answer is for. None is a descriptor the wallet does not hold, and equally one it holds unranged: an entry with no `range` watches one script and has no index to be widened from, which is the same "nothing to include" to whoever is building the next request. The union of the entries where a wallet holds the same expression more than once -- the same descriptor imported as both the receiving and the change chain, which Core allows and which `listdescriptors` distinguishes only for an active descriptor, `internal` being defined for those alone. Asking for the union is accepted for either of them, where asking for one entry's range can be a narrowing of the other's. """ wanted = _comparable(descriptor) ranges = [ entry["range"] for entry in reply["descriptors"] if _comparable(str(entry["desc"])) == wanted and "range" in entry ] if not ranges: return None return ( min(int(key_range[0]) for key_range in ranges), max(int(key_range[1]) for key_range in ranges), )
[docs] def widened_range( wanted: tuple[int, int], watched: tuple[int, int] | None = None ) -> tuple[int, int]: """Return the range to import: the one wanted, and never a narrower one. Core widens every ranged import to its keypool -- `next_index` plus a thousand scripts by default, whatever the request asked for -- and then refuses any later import that would narrow what it widened to: "New range must include current range" is what the second request is answered, and the whole import fails on it. So a caller whose range has grown asks for the union of the two, which is this, and importing a descriptor again is idempotent because of it. `watched` is what `watched_range` read of the wallet, and None is the descriptor it does not hold yet: `DEFAULT_RANGE` stands in for it, that being what Core widens a first import to anyway -- asking for it makes the reply state which indexes were imported instead of leaving a caller to assume them. A node whose keypool is not the default needs no allowance here: whatever it widened to is what the next `listdescriptors` says, and the union with that is what the next import asks for. What the widening costs is worth stating where it is decided: a wallet watches every script of the range, so the addresses past the ones a caller meant are the node's too, and money paid to one of them is money that wallet reports. An import of exactly what is meant, and no keypool, is what an unranged descriptor per script is for. """ start, end = wanted _assert_key_range(start, end) watched_start, watched_end = DEFAULT_RANGE if watched is None else watched return (min(start, watched_start), max(end, watched_end))
[docs] def assert_imported( requests: Sequence[Mapping[str, Any]], answers: Sequence[Mapping[str, Any]] ) -> None: """Refuse an `importdescriptors` reply that did not honour every request. Core answers one object per request instead of failing the call, so a request it did not honour arrives as `success: false` inside what the rpc layer calls a reply -- a result, which nothing under the caller has any reason to doubt. Left unread, a wallet goes on watching less than its owner believes it does, and the first thing to say so is a balance short of a deposit. The request is what names the failure: an answer carries the error and not the descriptor it was for, so the two are read in step, and a reply of the wrong length is itself a node that did not answer this. `warnings` is not read, that being what Core says about a request it did honour -- "Range not given, using default keypool range" is the one `DEFAULT_RANGE` exists to avoid -- and neither is a refusal turned into a value error: the request was one Core parsed, and what it refused is the state of a wallet, which no argument of the caller's spells. """ if len(requests) != len(answers): err_msg = f"{len(requests)} import requests, {len(answers)} answers" raise BTClibRuntimeError(err_msg) for request, answer in zip(requests, answers, strict=True): if not answer.get("success"): err_msg = f"import refused for {request.get('desc')}: {answer.get('error')}" raise BTClibRuntimeError(err_msg)