Source code for btclib_wallet.tx_or_psbt
# 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.
"""One entry point for a transaction or a psbt, in whatever it arrives as.
A transaction copied from a block explorer is hex, a
`walletcreatefundedpsbt` reply is base64, a QR code and a file are
bytes -- and which of `Tx.parse`, `Psbt.parse` and `Psbt.b64decode`
applies is a question a caller holding one of them should not have to
answer first. It is also a question with an unambiguous answer:
BIP174's five-byte `<magic>` is what a psbt begins with and what a
transaction cannot, which is the very reason the `0xff` is in it.
So this module sniffs, and delegates. The parsers stay whole -- one
refuses what does not deserialize, the other refuses what follows a
psbt (issue btclib-org/btclib#179) -- and no byte either of them reads is read
here: merging the two into one lenient reader is how a dispatcher stops being a
dispatcher, and it is the parsers that would pay for it.
It sits above `tx` and `psbt` rather than inside either, because its
answer is one or the other and `tx` may not import `psbt`.
"""
from __future__ import annotations
import base64
import binascii
from btclib.alias import String
from btclib.exceptions import BTClibValueError
from btclib.tx import Tx
from btclib.utils import bytes_from_octets
from btclib_wallet.psbt.psbt import PSBT_MAGIC_BYTES, Psbt
__all__ = [
"tx_or_psbt_from_any",
]
def _octets_from_text(text: str) -> bytes:
"""Return the bytes a hex-string or a base64 string encodes.
Hex first, because a hex-string whose length is a multiple of four
is also a valid base64 encoding -- of something else entirely. The
other way round there is nothing to resolve: base64's alphabet has
every letter, so a base64 string is almost never hex, and BIP174's
magic makes one of them start with `cHNidP8`.
"""
# whitespace and nothing else is dropped, since base64 arrives
# wrapped as often as not -- and bytes.fromhex has skipped it since
# python 3.7 anyway, so this only makes the two consistent
packed = "".join(text.split())
try:
return bytes.fromhex(packed)
except ValueError:
pass
try:
# validate=True: without it b64decode drops whatever is outside
# the alphabet instead of refusing it, so text that is not base64
# at all decodes to bytes that are not anything
return base64.b64decode(packed, validate=True)
except binascii.Error as e:
raise BTClibValueError("neither hex nor base64") from e
def _octets_from_any(data: String) -> bytes:
"""Return the bytes behind hex, base64, or bytes.
Bytes are text when they are ascii and decode as hex or base64, and
are themselves otherwise: `Path.read_bytes` is how a psbt or a
transaction comes out of a file, whichever of the three encodings
the file holds, and a caller who read one that way should not have
to know which. Nothing is lost to the ambiguity -- a serialized
transaction begins with a four-byte version that is not printable,
and a serialized psbt with a `0xff` that is not ascii at all.
"""
if isinstance(data, str):
return _octets_from_text(data)
# the refusal of what is neither text nor bytes is bytes_from_octets's
# to give, `split` below being str's and `decode` bytes': a memoryview
# has no `decode`, and the coercion is what makes it bytes
raw = bytes_from_octets(data)
try:
text = raw.decode("ascii")
except UnicodeDecodeError:
return raw
try:
return _octets_from_text(text)
except BTClibValueError:
return raw
[docs]
def tx_or_psbt_from_any(data: String, *, check_validity: bool = True) -> Tx | Psbt:
"""Return the Psbt or the Tx the data holds, in whatever encoding.
hex, base64 or bytes; a `Psbt` when BIP174's magic is what the bytes
begin with, a `Tx` when it is not.
"""
octets = _octets_from_any(data)
if not octets:
# what Tx.parse would answer here is "not enough binary data for
# var_int", which describes a field the caller never had
raise BTClibValueError("no data")
if octets.startswith(PSBT_MAGIC_BYTES):
return Psbt.parse(octets, check_validity=check_validity)
return Tx.parse(octets, check_validity=check_validity)