Source code for btclib_node.p2p.messages.errors

# 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.

"""`Reject`, BIP61's message, and the codes it carries.

`p2p.callbacks.reject` is the only handler that reads one, logging what
a peer sent; this node never constructs or sends one of its own.

Every way `parse` below refuses a payload is a `BTClibException`, which
is what `handle_p2p` (`p2p/main.py`) sorts a peer's fault from this
node's by: `btclib.var_int.parse`'s own refusal of a length prefix, and
`InvalidRejectPayloadError` for everything this module decides itself.
"""

import enum
from dataclasses import dataclass
from typing import TYPE_CHECKING, override

from btclib import var_int
from btclib.p2p.payload import Payload
from btclib.utils import bytesio_from_binarydata

from btclib_node.exceptions import InvalidRejectPayloadError

if TYPE_CHECKING:
    from io import BytesIO

    from btclib.alias import BinaryData

__all__ = ["Reject", "RejectCode"]

# BIP61's tx and block rejects each append the hash of what was
# rejected to the common payload, and its version reject appends
# nothing, so what follows `reason` is either absent or exactly this
# many octets
_HASH_SIZE = 32


def _read(stream: BytesIO, size: int, field: str) -> bytes:
    """Return `field`'s own `size` octets, refusing a shorter stream.

    `BytesIO.read` answers a stream that has run out with whatever is
    left rather than with an error, so a payload cut short mid-field
    would otherwise parse as a shorter one a peer never sent.
    """
    octets = stream.read(size)
    if len(octets) != size:
        err_msg = f"{field} wants {size} octets, {len(octets)} left"
        raise InvalidRejectPayloadError(err_msg)
    return octets


def _parse_str(stream: BytesIO, field: str) -> str:
    """Return the var_str `field`, refusing octets no utf-8 decodes.

    The length prefix is `btclib.var_int.parse`'s to refuse, its
    `BTClibValueError` already being the family `handle_p2p` reads as
    the peer's fault.
    """
    octets = _read(stream, var_int.parse(stream), field)
    try:
        return octets.decode()
    except UnicodeDecodeError as e:
        err_msg = f"{field} is not utf-8"
        raise InvalidRejectPayloadError(err_msg) from e


[docs] class RejectCode(enum.IntEnum): """BIP61's own reject codes, as `Reject.code` carries them on the wire.""" malformed = 0x01 invalid = 0x10 obsolete = 0x11 duplicate = 0x12 nonstandard = 0x40 dust = 0x41 insufficientfee = 0x42 checkpoint = 0x43
[docs] @dataclass class Reject(Payload): """BIP61's `reject` message: what a peer refused, and why. The module docstring above is where its one reader is named. """ command = "reject" message: str code: RejectCode reason: str data: bytes
[docs] @classmethod def parse( cls, data: BinaryData, *, check_validity: bool = True, # noqa: ARG003 ) -> Reject: """Parse a `Reject` from the bytes a peer sent. Accepts exactly what `serialize` below writes, so a payload holding anything else -- a field cut short, a trailing octet past the hash -- is refused rather than parsed into an object that reserializes to something the peer did not send. """ # every btclib Payload's own parse/serialize pair takes # check_validity, called polymorphically (Connection.py's own # payload.serialize(check_validity=False) among them) without a # caller knowing which subclass is on the other end -- kept # here for that shared shape even though Reject's own wire # format has nothing check_validity would gate. serialize # below carries the same parameter, unread the same way, but # is @override and so is not itself one of ARG's own findings. stream = bytesio_from_binarydata(data) message = _parse_str(stream, "message") octet = _read(stream, 1, "code")[0] try: code = RejectCode(octet) except ValueError as e: err_msg = f"not a BIP61 reject code: {octet:#04x}" raise InvalidRejectPayloadError(err_msg) from e reason = _parse_str(stream, "reason") # the wire carries a hash in internal order, and everything # here holds one the way it is displayed, as an inventory does rejected = stream.read() if rejected and len(rejected) != _HASH_SIZE: err_msg = f"a hash is {_HASH_SIZE} octets, not {len(rejected)}" raise InvalidRejectPayloadError(err_msg) return cls(message, code, reason, rejected[::-1])
[docs] @override def serialize(self, *, check_validity: bool = True) -> bytes: payload = var_int.serialize(len(self.message)) payload += self.message.encode() payload += self.code.to_bytes(1, "little") payload += var_int.serialize(len(self.reason)) payload += self.reason.encode() payload += self.data[::-1] return payload