From 8a3e44211232e3e33f35c0546fed708faf6c0213 Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 3 Jul 2026 00:00:20 +0800 Subject: [PATCH 1/5] refactor(encoding): replace standalone functions with Reader/Writer classes - Introduce Writer class with write_uint8/write_uint16/write_uint32/write_var_int/write_str/write_fixed_bytes methods - Introduce Reader class with read_uint8/read_uint16/read_uint32/read_var_int/read_str/read_fixed_bytes methods - Rename append_* to write_* for symmetry with read_* - Update all domain types (Block, Header, Certificate, Transaction, Hash, Address, Amount, etc.) to accept Writer/Reader instead of raw io types - Update ABCs (Signature, PublicKey, Payload) encode/decode signatures - Update all tests and examples - Remove dead TYPE_CHECKING/io imports --- examples/example_decode_block.py | 4 +- pactus/block/block.py | 19 ++-- pactus/block/certificate.py | 41 ++++---- pactus/block/header.py | 35 +++---- pactus/crypto/address.py | 18 ++-- pactus/crypto/bls/public_key.py | 14 +-- pactus/crypto/bls/signature.py | 15 +-- pactus/crypto/ed25519/public_key.py | 14 +-- pactus/crypto/ed25519/signature.py | 15 +-- pactus/crypto/hash.py | 15 +-- pactus/crypto/public_key.py | 8 +- pactus/crypto/secp256k1/public_key.py | 14 +-- pactus/crypto/secp256k1/signature.py | 15 +-- pactus/crypto/signature.py | 8 +- pactus/encoding/__init__.py | 3 + pactus/encoding/encoding.py | 127 +++++++++++++----------- pactus/transaction/payload/_payload.py | 3 +- pactus/transaction/payload/bond.py | 33 +++--- pactus/transaction/payload/sortition.py | 19 ++-- pactus/transaction/payload/transfer.py | 22 ++-- pactus/transaction/payload/unbond.py | 14 +-- pactus/transaction/payload/withdraw.py | 22 ++-- pactus/transaction/transaction.py | 81 ++++++++------- pactus/types/amount.py | 15 +-- pactus/types/height.py | 15 +-- pactus/types/round.py | 15 +-- tests/test_amount.py | 16 +-- tests/test_block.py | 4 +- tests/test_encoding.py | 66 +++++------- tests/test_transaction.py | 6 +- 30 files changed, 309 insertions(+), 387 deletions(-) diff --git a/examples/example_decode_block.py b/examples/example_decode_block.py index 8d24871..dc993db 100644 --- a/examples/example_decode_block.py +++ b/examples/example_decode_block.py @@ -11,10 +11,10 @@ """ import asyncio -import io import random from pactus.block import Block +from pactus.encoding import Reader from pactus_jsonrpc.client import PactusOpenRPCClient TESTNET_RPC = "https://testnet1.pactus.org/jsonrpc" @@ -36,7 +36,7 @@ async def fetch_and_decode_block(): print(f"\nFetching block at height {height}...") res = await client.pactus.blockchain.get_block(height=height, verbosity=0) - block = Block.decode(io.BytesIO(bytes.fromhex(res["data"]))) + block = Block.decode(Reader(bytes.fromhex(res["data"]))) print(f" - Hash: {block.id}") print(f" - Version: {block.header.version}") diff --git a/pactus/block/block.py b/pactus/block/block.py index dab5b5d..f4011eb 100644 --- a/pactus/block/block.py +++ b/pactus/block/block.py @@ -1,11 +1,10 @@ from __future__ import annotations import hashlib -import io import struct from pactus.crypto.hash import Hash -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from pactus.transaction import Transaction from .certificate import Certificate @@ -24,22 +23,22 @@ def __init__( self.transactions = transactions @classmethod - def decode(cls, buf: io.BufferedReader) -> Block: + def decode(cls, reader: Reader) -> Block: """Decode a Block from the stream.""" - header = Header.decode(buf) + header = Header.decode(reader) # Genesis block has no certificate is_genesis = header.prev_block_hash.is_undef() prev_cert = None if not is_genesis: - prev_cert = Certificate.decode(buf) + prev_cert = Certificate.decode(reader) - num_txs = encoding.read_var_int(buf) + num_txs = reader.read_var_int() transactions = [] for _ in range(num_txs): - tx = Transaction.decode(buf) + tx = Transaction.decode(reader) transactions.append(tx) return cls(header, prev_cert, transactions) @@ -55,9 +54,9 @@ def id(self) -> Hash: return Hash(hashlib.blake2b(buf, digest_size=32).digest()) def _header_bytes(self) -> bytes: - buf = io.BytesIO() - self.header.encode(buf) - return buf.getvalue() + writer = Writer() + self.header.encode(writer) + return writer.bytes() def _txs_root(self) -> bytes: return Block._merkle_root([tx.id().data for tx in self.transactions]) diff --git a/pactus/block/certificate.py b/pactus/block/certificate.py index 5ed669d..00cffdf 100644 --- a/pactus/block/certificate.py +++ b/pactus/block/certificate.py @@ -1,10 +1,9 @@ from __future__ import annotations import hashlib -import io from pactus.crypto.bls.signature import Signature -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from pactus.types.height import Height from pactus.types.round import Round @@ -25,40 +24,40 @@ def __init__( self.signature = signature @classmethod - def decode(cls, buf: io.BufferedReader) -> Certificate: + def decode(cls, reader: Reader) -> Certificate: """Decode a Certificate from the stream.""" - height = Height.decode(buf) - round_ = Round.decode(buf) + height = Height.decode(reader) + round_ = Round.decode(reader) - num_committers = encoding.read_var_int(buf) + num_committers = reader.read_var_int() committers = [] for _ in range(num_committers): - n = encoding.read_var_int(buf) + n = reader.read_var_int() committers.append(n) - num_absentees = encoding.read_var_int(buf) + num_absentees = reader.read_var_int() absentees = [] for _ in range(num_absentees): - n = encoding.read_var_int(buf) + n = reader.read_var_int() absentees.append(n) - signature = Signature.decode(buf) + signature = Signature.decode(reader) return cls(height, round_, committers, absentees, signature) - def encode(self, buf: io.Writer) -> None: - self.height.encode(buf) - self.round.encode(buf) - encoding.append_var_int(buf, len(self.committers)) + def encode(self, writer: Writer) -> None: + self.height.encode(writer) + self.round.encode(writer) + writer.write_var_int(len(self.committers)) for n in self.committers: - encoding.append_var_int(buf, n) - encoding.append_var_int(buf, len(self.absentees)) + writer.write_var_int(n) + writer.write_var_int(len(self.absentees)) for n in self.absentees: - encoding.append_var_int(buf, n) - self.signature.encode(buf) + writer.write_var_int(n) + self.signature.encode(writer) def hash(self) -> bytes: """Return the certificate hash (blake2b-256 of encoded bytes).""" - buf = io.BytesIO() - self.encode(buf) - return hashlib.blake2b(buf.getvalue(), digest_size=32).digest() + writer = Writer() + self.encode(writer) + return hashlib.blake2b(writer.bytes(), digest_size=32).digest() diff --git a/pactus/block/header.py b/pactus/block/header.py index 57d3426..6b88ffc 100644 --- a/pactus/block/header.py +++ b/pactus/block/header.py @@ -1,13 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import io - from pactus.crypto.address import Address from pactus.crypto.hash import Hash -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer class Header: @@ -28,14 +23,14 @@ def __init__( self.proposer_address = proposer_address @classmethod - def decode(cls, buf: io.BufferedReader) -> Header: + def decode(cls, reader: Reader) -> Header: """Decode a Header from the stream.""" - version = encoding.read_uint8(buf) - unix_time = encoding.read_uint32(buf) - prev_block_hash = Hash.decode(buf) - state_root = Hash.decode(buf) - sortition_seed = encoding.read_fixed_bytes(buf, 48) - proposer_address = Address.decode(buf) + version = reader.read_uint8() + unix_time = reader.read_uint32() + prev_block_hash = Hash.decode(reader) + state_root = Hash.decode(reader) + sortition_seed = reader.read_fixed_bytes(48) + proposer_address = Address.decode(reader) return cls( version, @@ -46,10 +41,10 @@ def decode(cls, buf: io.BufferedReader) -> Header: proposer_address, ) - def encode(self, buf: io.Writer) -> None: - encoding.append_uint8(buf, self.version) - encoding.append_uint32(buf, self.unix_time) - self.prev_block_hash.encode(buf) - self.state_root.encode(buf) - encoding.append_fixed_bytes(buf, self.sortition_seed) - self.proposer_address.encode(buf) + def encode(self, writer: Writer) -> None: + writer.write_uint8(self.version) + writer.write_uint32(self.unix_time) + self.prev_block_hash.encode(writer) + self.state_root.encode(writer) + writer.write_fixed_bytes(self.sortition_seed) + self.proposer_address.encode(writer) diff --git a/pactus/crypto/address.py b/pactus/crypto/address.py index 51a7537..7a3c478 100644 --- a/pactus/crypto/address.py +++ b/pactus/crypto/address.py @@ -1,15 +1,11 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING from pactus.crypto.hrp import HRP -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from pactus.utils import utils -if TYPE_CHECKING: - import io - # Address format: hrp + `1` + type + data + checksum ADDRESS_SIZE = 21 @@ -90,18 +86,18 @@ def is_account_address(self) -> bool: def is_validator_address(self) -> bool: return self.address_type() == AddressType.VALIDATOR - def encode(self, buf: io.Writer) -> None: + def encode(self, writer: Writer) -> None: if self.is_treasury_address(): - encoding.append_uint8(buf, AddressType.TREASURY.value) + writer.write_uint8(AddressType.TREASURY.value) else: - encoding.append_fixed_bytes(buf, self.raw_bytes()) + writer.write_fixed_bytes(self.raw_bytes()) @classmethod - def decode(cls, buf: io.BufferedReader) -> Address: + def decode(cls, reader: Reader) -> Address: """Decode an Address from the stream.""" - addr_type = encoding.read_uint8(buf) + addr_type = reader.read_uint8() if addr_type == AddressType.TREASURY.value: return cls(AddressType.TREASURY, bytes(20)) - data = encoding.read_fixed_bytes(buf, 20) + data = reader.read_fixed_bytes(20) return cls(AddressType(addr_type), data) diff --git a/pactus/crypto/bls/public_key.py b/pactus/crypto/bls/public_key.py index 22d055f..4e50cb5 100644 --- a/pactus/crypto/bls/public_key.py +++ b/pactus/crypto/bls/public_key.py @@ -1,22 +1,18 @@ from __future__ import annotations import hashlib -from typing import TYPE_CHECKING from ripemd.ripemd160 import ripemd160 from pactus.crypto.address import Address, AddressType from pactus.crypto.hrp import HRP -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from pactus.utils import utils from .bls12_381.bls_sig_g1 import aggregate_pubs, verify from .bls12_381.serdesZ import deserialize, serialize from .signature import DST, SIGNATURE_TYPE_BLS, Signature -if TYPE_CHECKING: - import io - PUBLIC_KEY_SIZE = 96 @@ -65,12 +61,12 @@ def string(self) -> str: self.raw_bytes(), ) - def encode(self, buf: io.Writer) -> None: - encoding.append_fixed_bytes(buf, self.raw_bytes()) + def encode(self, writer: Writer) -> None: + writer.write_fixed_bytes(self.raw_bytes()) @classmethod - def decode(cls, buf: io.BufferedReader) -> PublicKey: - data = encoding.read_fixed_bytes(buf, PUBLIC_KEY_SIZE) + def decode(cls, reader: Reader) -> PublicKey: + data = reader.read_fixed_bytes(PUBLIC_KEY_SIZE) return cls.from_bytes(data) def account_address(self) -> Address: diff --git a/pactus/crypto/bls/signature.py b/pactus/crypto/bls/signature.py index f2ad220..d8d0ad3 100644 --- a/pactus/crypto/bls/signature.py +++ b/pactus/crypto/bls/signature.py @@ -1,15 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from .bls12_381.bls_sig_g1 import aggregate_sigs from .bls12_381.serdesZ import deserialize, serialize -if TYPE_CHECKING: - import io - SIGNATURE_SIZE = 48 SIGNATURE_TYPE_BLS = 1 DST = b"BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_" @@ -44,11 +39,11 @@ def raw_bytes(self) -> bytes: def string(self) -> str: return self.raw_bytes().hex() - def encode(self, buf: io.Writer) -> None: - encoding.append_fixed_bytes(buf, self.raw_bytes()) + def encode(self, writer: Writer) -> None: + writer.write_fixed_bytes(self.raw_bytes()) @classmethod - def decode(cls, buf: io.BufferedReader) -> Signature: - data = encoding.read_fixed_bytes(buf, SIGNATURE_SIZE) + def decode(cls, reader: Reader) -> Signature: + data = reader.read_fixed_bytes(SIGNATURE_SIZE) point_g1 = deserialize(data, is_ell2=False) return cls(point_g1) diff --git a/pactus/crypto/ed25519/public_key.py b/pactus/crypto/ed25519/public_key.py index f1238da..f5cbb81 100644 --- a/pactus/crypto/ed25519/public_key.py +++ b/pactus/crypto/ed25519/public_key.py @@ -1,7 +1,6 @@ from __future__ import annotations import hashlib -from typing import TYPE_CHECKING from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric import ed25519 @@ -9,14 +8,11 @@ from pactus.crypto.address import Address, AddressType from pactus.crypto.hrp import HRP -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from pactus.utils import utils from .signature import SIGNATURE_TYPE_ED25519, Signature -if TYPE_CHECKING: - import io - PUBLIC_KEY_SIZE = 32 @@ -54,12 +50,12 @@ def string(self) -> str: self.raw_bytes(), ) - def encode(self, buf: io.Writer) -> None: - encoding.append_fixed_bytes(buf, self.raw_bytes()) + def encode(self, writer: Writer) -> None: + writer.write_fixed_bytes(self.raw_bytes()) @classmethod - def decode(cls, buf: io.BufferedReader) -> PublicKey: - data = encoding.read_fixed_bytes(buf, PUBLIC_KEY_SIZE) + def decode(cls, reader: Reader) -> PublicKey: + data = reader.read_fixed_bytes(PUBLIC_KEY_SIZE) return cls(ed25519.Ed25519PublicKey.from_public_bytes(data)) def account_address(self) -> Address: diff --git a/pactus/crypto/ed25519/signature.py b/pactus/crypto/ed25519/signature.py index e828e0e..0b51c03 100644 --- a/pactus/crypto/ed25519/signature.py +++ b/pactus/crypto/ed25519/signature.py @@ -1,11 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from pactus.encoding import encoding - -if TYPE_CHECKING: - import io +from pactus.encoding import Reader, Writer SIGNATURE_SIZE = 64 SIGNATURE_TYPE_ED25519 = 3 @@ -31,10 +26,10 @@ def raw_bytes(self) -> bytes: def string(self) -> str: return self.sig.hex() - def encode(self, buf: io.Writer) -> None: - encoding.append_fixed_bytes(buf, self.sig) + def encode(self, writer: Writer) -> None: + writer.write_fixed_bytes(self.sig) @classmethod - def decode(cls, buf: io.BufferedReader) -> Signature: - data = encoding.read_fixed_bytes(buf, SIGNATURE_SIZE) + def decode(cls, reader: Reader) -> Signature: + data = reader.read_fixed_bytes(SIGNATURE_SIZE) return cls(data) diff --git a/pactus/crypto/hash.py b/pactus/crypto/hash.py index 5369c3a..5e77c55 100644 --- a/pactus/crypto/hash.py +++ b/pactus/crypto/hash.py @@ -1,11 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from pactus.encoding import encoding - -if TYPE_CHECKING: - import io +from pactus.encoding import Reader, Writer HASH_SIZE = 32 @@ -33,11 +28,11 @@ def __str__(self) -> str: def is_undef(self) -> bool: return self.data == bytes(HASH_SIZE) - def encode(self, buf: io.Writer) -> None: - encoding.append_fixed_bytes(buf, self.data) + def encode(self, writer: Writer) -> None: + writer.write_fixed_bytes(self.data) @classmethod - def decode(cls, buf: io.BufferedReader) -> Hash: + def decode(cls, reader: Reader) -> Hash: """Decode a Hash from the stream.""" - data = encoding.read_fixed_bytes(buf, HASH_SIZE) + data = reader.read_fixed_bytes(HASH_SIZE) return cls(data) diff --git a/pactus/crypto/public_key.py b/pactus/crypto/public_key.py index 40a7851..4a17e3d 100644 --- a/pactus/crypto/public_key.py +++ b/pactus/crypto/public_key.py @@ -3,9 +3,9 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING -if TYPE_CHECKING: - import io +from pactus.encoding import Reader, Writer +if TYPE_CHECKING: from .signature import Signature @@ -24,12 +24,12 @@ def string(self) -> str: pass @abstractmethod - def encode(self, buf: io.Writer) -> None: + def encode(self, writer: Writer) -> None: pass @classmethod @abstractmethod - def decode(cls, buf: io.BufferedReader) -> PublicKey: + def decode(cls, reader: Reader) -> PublicKey: pass @abstractmethod diff --git a/pactus/crypto/secp256k1/public_key.py b/pactus/crypto/secp256k1/public_key.py index 3ea36d3..25e1c6d 100644 --- a/pactus/crypto/secp256k1/public_key.py +++ b/pactus/crypto/secp256k1/public_key.py @@ -2,21 +2,17 @@ import hashlib from functools import partial -from typing import TYPE_CHECKING import secp256k1 from ripemd.ripemd160 import ripemd160 from pactus.crypto.address import Address, AddressType from pactus.crypto.hrp import HRP -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from pactus.utils import utils from .signature import SIGNATURE_TYPE_SECP256K1, Signature -if TYPE_CHECKING: - import io - PUBLIC_KEY_SIZE = 33 # Compressed public key @@ -54,12 +50,12 @@ def string(self) -> str: self.raw_bytes(), ) - def encode(self, buf: io.Writer) -> None: - encoding.append_fixed_bytes(buf, self.raw_bytes()) + def encode(self, writer: Writer) -> None: + writer.write_fixed_bytes(self.raw_bytes()) @classmethod - def decode(cls, buf: io.BufferedReader) -> PublicKey: - data = encoding.read_fixed_bytes(buf, PUBLIC_KEY_SIZE) + def decode(cls, reader: Reader) -> PublicKey: + data = reader.read_fixed_bytes(PUBLIC_KEY_SIZE) return cls(secp256k1.PublicKey(data, raw=True)) def account_address(self) -> Address: diff --git a/pactus/crypto/secp256k1/signature.py b/pactus/crypto/secp256k1/signature.py index dc5ec95..5089b08 100644 --- a/pactus/crypto/secp256k1/signature.py +++ b/pactus/crypto/secp256k1/signature.py @@ -1,11 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from pactus.encoding import encoding - -if TYPE_CHECKING: - import io +from pactus.encoding import Reader, Writer SIGNATURE_SIZE = 64 SIGNATURE_TYPE_SECP256K1 = 4 @@ -31,10 +26,10 @@ def raw_bytes(self) -> bytes: def string(self) -> str: return self.sig.hex() - def encode(self, buf: io.Writer) -> None: - encoding.append_fixed_bytes(buf, self.sig) + def encode(self, writer: Writer) -> None: + writer.write_fixed_bytes(self.sig) @classmethod - def decode(cls, buf: io.BufferedReader) -> Signature: - data = encoding.read_fixed_bytes(buf, SIGNATURE_SIZE) + def decode(cls, reader: Reader) -> Signature: + data = reader.read_fixed_bytes(SIGNATURE_SIZE) return cls(data) diff --git a/pactus/crypto/signature.py b/pactus/crypto/signature.py index f2d0ec7..2a2c8cb 100644 --- a/pactus/crypto/signature.py +++ b/pactus/crypto/signature.py @@ -1,10 +1,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING -if TYPE_CHECKING: - import io +from pactus.encoding import Reader, Writer class Signature(ABC): @@ -22,10 +20,10 @@ def string(self) -> str: pass @abstractmethod - def encode(self, buf: io.Writer) -> None: + def encode(self, writer: Writer) -> None: pass @classmethod @abstractmethod - def decode(cls, buf: io.BufferedReader) -> Signature: + def decode(cls, reader: Reader) -> Signature: pass diff --git a/pactus/encoding/__init__.py b/pactus/encoding/__init__.py index e69de29..f573a40 100644 --- a/pactus/encoding/__init__.py +++ b/pactus/encoding/__init__.py @@ -0,0 +1,3 @@ +from pactus.encoding.encoding import Reader, Writer + +__all__ = ["Reader", "Writer"] diff --git a/pactus/encoding/encoding.py b/pactus/encoding/encoding.py index 78f23ed..292c956 100644 --- a/pactus/encoding/encoding.py +++ b/pactus/encoding/encoding.py @@ -1,82 +1,95 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import io -if TYPE_CHECKING: - import io +class Writer: + """Writer wraps an io.BytesIO for encoding Pactus binary data.""" -def append_uint8(buf: io.Writer, val: int) -> None: - buf.write(val.to_bytes(1, "little")) + def __init__(self, buf: io.BytesIO | None = None) -> None: + self._buf = buf or io.BytesIO() + def write_uint8(self, val: int) -> None: + self._buf.write(val.to_bytes(1, "little")) -def append_uint16(buf: io.Writer, val: int) -> None: - buf.write(val.to_bytes(2, "little")) + def write_uint16(self, val: int) -> None: + self._buf.write(val.to_bytes(2, "little")) + def write_uint32(self, val: int) -> None: + self._buf.write(val.to_bytes(4, "little")) -def append_uint32(buf: io.Writer, val: int) -> None: - buf.write(val.to_bytes(4, "little")) + def write_var_int(self, val: int) -> None: + while val >= 0x80: + n = (val & 0x7F) | 0x80 + self._buf.write(bytes([n])) + val >>= 7 + self._buf.write(bytes([val])) -def append_str(buf: io.Writer, val: str) -> None: - append_var_int(buf, len(val)) - append_fixed_bytes(buf, bytes(val, "utf-8")) + def write_str(self, val: str) -> None: + encoded = bytes(val, "utf-8") + self.write_var_int(len(encoded)) + self.write_fixed_bytes(encoded) + def write_fixed_bytes(self, data: bytes) -> None: + self._buf.write(data) -def append_var_int(buf: io.Writer, val: int) -> None: - while val >= 0x80: - n = (val & 0x7F) | 0x80 - buf.write(bytes([n])) - val >>= 7 + def write(self, data: bytes) -> None: + """Raw write passthrough to the underlying buffer.""" + self._buf.write(data) - buf.write(bytes([val])) + def bytes(self) -> bytes: + """Return the accumulated bytes.""" + return self._buf.getvalue() -def append_fixed_bytes(buf: io.Writer, data: bytes) -> None: - buf.write(data) +class Reader: + """Reader wraps a byte source for decoding Pactus binary data.""" + def __init__(self, data: bytes | io.BytesIO | io.BufferedReader) -> None: + if isinstance(data, (io.BytesIO, io.BufferedReader)): + self._buf = data + else: + self._buf = io.BytesIO(data) -def _read(buf: io.BufferedReader, size: int) -> bytes: - data = buf.read(size) - if len(data) != size: - msg = "unexpected end of data while reading" - raise ValueError(msg) + def _read(self, size: int) -> bytes: + data = self._buf.read(size) + if len(data) != size: + msg = "unexpected end of data while reading" + raise ValueError(msg) + return data - return data + def read_uint8(self) -> int: + return int.from_bytes(self._read(1), "little", signed=False) + def read_uint16(self) -> int: + return int.from_bytes(self._read(2), "little", signed=False) -def read_uint8(buf: io.BufferedReader) -> int: - return int.from_bytes(_read(buf, 1), "little", signed=False) + def read_uint32(self) -> int: + return int.from_bytes(self._read(4), "little", signed=False) + def read_var_int(self) -> int: + """Read a variable-length integer from the stream.""" + result = 0 + shift = 0 + while True: + byte = self.read_uint8() + result |= (byte & 0x7F) << shift + if (byte & 0x80) == 0: + break + shift += 7 + return result -def read_uint16(buf: io.BufferedReader) -> int: - return int.from_bytes(_read(buf, 2), "little", signed=False) + def read_fixed_bytes(self, size: int) -> bytes: + """Read a fixed number of bytes from the stream.""" + return self._read(size) + def read_str(self) -> str: + """Read a variable-length string (varint length + utf8 data) from the stream.""" + length = self.read_var_int() + raw = self.read_fixed_bytes(length) + return raw.decode("utf-8") -def read_uint32(buf: io.BufferedReader) -> int: - return int.from_bytes(_read(buf, 4), "little", signed=False) - - -def read_var_int(buf: io.BytesIO) -> int: - """Read a variable-length integer from the stream.""" - result = 0 - shift = 0 - while True: - byte = read_uint8(buf) - result |= (byte & 0x7F) << shift - if (byte & 0x80) == 0: - break - shift += 7 - return result - - -def read_fixed_bytes(buf: io.BytesIO, size: int) -> bytes: - """Read a fixed number of bytes from the stream.""" - return _read(buf, size) - - -def read_str(buf: io.BytesIO) -> str: - """Read a variable-length string (varint length + utf8 data) from the stream.""" - length = read_var_int(buf) - raw = read_fixed_bytes(buf, length) - return raw.decode("utf-8") + def read(self, size: int) -> bytes: + """Raw read passthrough to the underlying buffer.""" + return self._read(size) diff --git a/pactus/transaction/payload/_payload.py b/pactus/transaction/payload/_payload.py index 7fcf7fa..6478c96 100644 --- a/pactus/transaction/payload/_payload.py +++ b/pactus/transaction/payload/_payload.py @@ -2,6 +2,7 @@ from enum import Enum from pactus.crypto.address import Address +from pactus.encoding import Writer class PayloadType(Enum): @@ -14,7 +15,7 @@ class PayloadType(Enum): class Payload(ABC): @abstractmethod - def encode(self, buf: bytes) -> bytes: + def encode(self, writer: Writer) -> None: """Append the payload data to the buffer.""" @abstractmethod diff --git a/pactus/transaction/payload/bond.py b/pactus/transaction/payload/bond.py index be44fed..e611e6c 100644 --- a/pactus/transaction/payload/bond.py +++ b/pactus/transaction/payload/bond.py @@ -1,17 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from pactus.crypto.address import Address from pactus.crypto.bls.public_key import PublicKey as BLSPublicKey -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from pactus.types.amount import Amount from ._payload import PayloadType -if TYPE_CHECKING: - import io - class BondPayload: def __init__( @@ -26,15 +21,15 @@ def __init__( self.public_key = public_key self.stake = stake - def encode(self, buf: io.Writer) -> None: - self.sender.encode(buf) - self.receiver.encode(buf) + def encode(self, writer: Writer) -> None: + self.sender.encode(writer) + self.receiver.encode(writer) if self.public_key is not None: - encoding.append_var_int(buf, 96) - self.public_key.encode(buf) + writer.write_var_int(96) + self.public_key.encode(writer) else: - encoding.append_var_int(buf, 0) - self.stake.encode(buf) + writer.write_var_int(0) + self.stake.encode(writer) def get_type(self) -> PayloadType: return PayloadType.BOND @@ -43,17 +38,17 @@ def signer(self) -> Address: return self.sender @classmethod - def decode(cls, buf: io.BufferedReader) -> BondPayload: - sender = Address.decode(buf) - receiver = Address.decode(buf) + def decode(cls, reader: Reader) -> BondPayload: + sender = Address.decode(reader) + receiver = Address.decode(reader) - pub_key_size = encoding.read_var_int(buf) + pub_key_size = reader.read_var_int() public_key = None if pub_key_size == 96: - public_key = BLSPublicKey.decode(buf) + public_key = BLSPublicKey.decode(reader) elif pub_key_size != 0: msg = f"invalid public key size: {pub_key_size}" raise ValueError(msg) - stake = Amount.decode(buf) + stake = Amount.decode(reader) return cls(sender, receiver, public_key, stake) diff --git a/pactus/transaction/payload/sortition.py b/pactus/transaction/payload/sortition.py index f22f131..1a34fd0 100644 --- a/pactus/transaction/payload/sortition.py +++ b/pactus/transaction/payload/sortition.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import io - from pactus.crypto.address import Address -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from ._payload import PayloadType @@ -16,9 +11,9 @@ def __init__(self, address: Address, proof: bytes) -> None: self.address = address self.proof = proof - def encode(self, buf: io.Writer) -> None: - self.address.encode(buf) - encoding.append_fixed_bytes(buf, self.proof) + def encode(self, writer: Writer) -> None: + self.address.encode(writer) + writer.write_fixed_bytes(self.proof) def get_type(self) -> PayloadType: return PayloadType.SORTITION @@ -27,7 +22,7 @@ def signer(self) -> Address: return self.address @classmethod - def decode(cls, buf: io.BufferedReader) -> SortitionPayload: - address = Address.decode(buf) - proof = encoding.read_fixed_bytes(buf, 48) + def decode(cls, reader: Reader) -> SortitionPayload: + address = Address.decode(reader) + proof = reader.read_fixed_bytes(48) return cls(address, proof) diff --git a/pactus/transaction/payload/transfer.py b/pactus/transaction/payload/transfer.py index 2fda5ec..e8deab1 100644 --- a/pactus/transaction/payload/transfer.py +++ b/pactus/transaction/payload/transfer.py @@ -1,11 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import io - from pactus.crypto.address import Address +from pactus.encoding import Reader, Writer from pactus.types.amount import Amount from ._payload import PayloadType @@ -17,10 +13,10 @@ def __init__(self, sender: Address, receiver: Address, amount: Amount) -> None: self.receiver = receiver self.amount = amount - def encode(self, buf: io.Writer) -> None: - self.sender.encode(buf) - self.receiver.encode(buf) - self.amount.encode(buf) + def encode(self, writer: Writer) -> None: + self.sender.encode(writer) + self.receiver.encode(writer) + self.amount.encode(writer) def get_type(self) -> PayloadType: return PayloadType.TRANSFER @@ -29,8 +25,8 @@ def signer(self) -> Address: return self.sender @classmethod - def decode(cls, buf: io.BufferedReader) -> TransferPayload: - sender = Address.decode(buf) - receiver = Address.decode(buf) - amount = Amount.decode(buf) + def decode(cls, reader: Reader) -> TransferPayload: + sender = Address.decode(reader) + receiver = Address.decode(reader) + amount = Amount.decode(reader) return cls(sender, receiver, amount) diff --git a/pactus/transaction/payload/unbond.py b/pactus/transaction/payload/unbond.py index 69bc18c..870e9d9 100644 --- a/pactus/transaction/payload/unbond.py +++ b/pactus/transaction/payload/unbond.py @@ -1,11 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import io - from pactus.crypto.address import Address +from pactus.encoding import Reader, Writer from ._payload import PayloadType @@ -14,8 +10,8 @@ class UnbondPayload: def __init__(self, validator: Address) -> None: self.validator = validator - def encode(self, buf: io.Writer) -> None: - self.validator.encode(buf) + def encode(self, writer: Writer) -> None: + self.validator.encode(writer) def get_type(self) -> PayloadType: return PayloadType.UNBOND @@ -24,6 +20,6 @@ def signer(self) -> Address: return self.validator @classmethod - def decode(cls, buf: io.BufferedReader) -> UnbondPayload: - validator = Address.decode(buf) + def decode(cls, reader: Reader) -> UnbondPayload: + validator = Address.decode(reader) return cls(validator) diff --git a/pactus/transaction/payload/withdraw.py b/pactus/transaction/payload/withdraw.py index e56494c..7220690 100644 --- a/pactus/transaction/payload/withdraw.py +++ b/pactus/transaction/payload/withdraw.py @@ -1,11 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import io - from pactus.crypto.address import Address +from pactus.encoding import Reader, Writer from pactus.types.amount import Amount from ._payload import PayloadType @@ -17,10 +13,10 @@ def __init__(self, from_addr: Address, to_addr: Address, amount: Amount) -> None self.to_addr = to_addr self.amount = amount - def encode(self, buf: io.Writer) -> None: - self.from_addr.encode(buf) - self.to_addr.encode(buf) - self.amount.encode(buf) + def encode(self, writer: Writer) -> None: + self.from_addr.encode(writer) + self.to_addr.encode(writer) + self.amount.encode(writer) def get_type(self) -> PayloadType: return PayloadType.WITHDRAW @@ -29,8 +25,8 @@ def signer(self) -> Address: return self.from_addr @classmethod - def decode(cls, buf: io.BufferedReader) -> WithdrawPayload: - from_addr = Address.decode(buf) - to_addr = Address.decode(buf) - amount = Amount.decode(buf) + def decode(cls, reader: Reader) -> WithdrawPayload: + from_addr = Address.decode(reader) + to_addr = Address.decode(reader) + amount = Amount.decode(reader) return cls(from_addr, to_addr, amount) diff --git a/pactus/transaction/transaction.py b/pactus/transaction/transaction.py index 321e46d..80d0417 100644 --- a/pactus/transaction/transaction.py +++ b/pactus/transaction/transaction.py @@ -1,7 +1,6 @@ from __future__ import annotations import hashlib -import io from pactus.crypto.address import Address, AddressType from pactus.crypto.bls.public_key import PublicKey as BLSPublicKey @@ -14,7 +13,7 @@ from pactus.crypto.secp256k1.public_key import PublicKey as Secp256k1PublicKey from pactus.crypto.secp256k1.signature import Signature as Secp256k1Signature from pactus.crypto.signature import Signature -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer from pactus.types.amount import Amount from pactus.types.height import Height @@ -50,25 +49,25 @@ def __init__( self.signature: Signature = None @classmethod - def decode(cls, buf: io.BufferedReader) -> Transaction: + def decode(cls, reader: Reader) -> Transaction: """Decode a Transaction from the stream.""" - flags = encoding.read_uint8(buf) - version = encoding.read_uint8(buf) - lock_time = Height.decode(buf) - fee = Amount.decode(buf) - memo = encoding.read_str(buf) - payload_type = encoding.read_uint8(buf) + flags = reader.read_uint8() + version = reader.read_uint8() + lock_time = Height.decode(reader) + fee = Amount.decode(reader) + memo = reader.read_str() + payload_type = reader.read_uint8() if payload_type == PayloadType.TRANSFER.value: - payload = TransferPayload.decode(buf) + payload = TransferPayload.decode(reader) elif payload_type == PayloadType.BOND.value: - payload = BondPayload.decode(buf) + payload = BondPayload.decode(reader) elif payload_type == PayloadType.SORTITION.value: - payload = SortitionPayload.decode(buf) + payload = SortitionPayload.decode(reader) elif payload_type == PayloadType.UNBOND.value: - payload = UnbondPayload.decode(buf) + payload = UnbondPayload.decode(reader) elif payload_type == PayloadType.WITHDRAW.value: - payload = WithdrawPayload.decode(buf) + payload = WithdrawPayload.decode(reader) else: msg = f"unknown payload type: {payload_type}" raise ValueError(msg) @@ -87,32 +86,32 @@ def decode(cls, buf: io.BufferedReader) -> Transaction: return tx signer_type = payload.signer().address_type() - tx.signature = Transaction._decode_signature(buf, signer_type) + tx.signature = Transaction._decode_signature(reader, signer_type) if (flags & FLAG_STRIPPED_PUBLIC_KEY) == 0: - tx.public_key = Transaction._decode_public_key(buf, signer_type) + tx.public_key = Transaction._decode_public_key(reader, signer_type) return tx @staticmethod - def _decode_signature(buf: io.BufferedReader, signer_type: AddressType) -> Signature: + def _decode_signature(reader: Reader, signer_type: AddressType) -> Signature: if signer_type in (AddressType.VALIDATOR, AddressType.BLS_ACCOUNT): - return BLSSignature.decode(buf) + return BLSSignature.decode(reader) if signer_type == AddressType.ED25519_ACCOUNT: - return Ed25519Signature.decode(buf) + return Ed25519Signature.decode(reader) if signer_type == AddressType.SECP256K1_ACCOUNT: - return Secp256k1Signature.decode(buf) + return Secp256k1Signature.decode(reader) msg = f"cannot decode signature for address type: {signer_type}" raise ValueError(msg) @staticmethod - def _decode_public_key(buf: io.BufferedReader, signer_type: AddressType) -> PublicKey: + def _decode_public_key(reader: Reader, signer_type: AddressType) -> PublicKey: if signer_type in (AddressType.VALIDATOR, AddressType.BLS_ACCOUNT): - return BLSPublicKey.decode(buf) + return BLSPublicKey.decode(reader) if signer_type == AddressType.ED25519_ACCOUNT: - return Ed25519PublicKey.decode(buf) + return Ed25519PublicKey.decode(reader) if signer_type == AddressType.SECP256K1_ACCOUNT: - return Secp256k1PublicKey.decode(buf) + return Secp256k1PublicKey.decode(reader) msg = f"cannot decode public key for address type: {signer_type}" raise ValueError(msg) @@ -166,21 +165,21 @@ def create_withdraw_tx( payload = WithdrawPayload(from_addr, to_addr, amount) return cls(lock_time, fee, memo, payload) - def _get_unsigned_bytes(self, buf: io.Writer) -> None: + def _get_unsigned_bytes(self, writer: Writer) -> None: """Write the unsigned bytes of the transaction to the buffer.""" - encoding.append_uint8(buf, self.flags) - encoding.append_uint8(buf, self.version) - self.lock_time.encode(buf) - self.fee.encode(buf) - encoding.append_str(buf, self.memo) - encoding.append_uint8(buf, self.payload.get_type().value) - self.payload.encode(buf) + writer.write_uint8(self.flags) + writer.write_uint8(self.version) + self.lock_time.encode(writer) + self.fee.encode(writer) + writer.write_str(self.memo) + writer.write_uint8(self.payload.get_type().value) + self.payload.encode(writer) def sign_bytes(self) -> bytes: """Return the bytes to be signed (everything except flags).""" - buf = io.BytesIO() - self._get_unsigned_bytes(buf) - return buf.getvalue()[1:] + writer = Writer() + self._get_unsigned_bytes(writer) + return writer.bytes()[1:] def id(self) -> Hash: """Return the transaction ID (blake2b-256 of sign bytes).""" @@ -188,16 +187,16 @@ def id(self) -> Hash: def sign(self, private_key: PrivateKey) -> bytes: """Sign the transaction and return signed bytes.""" - buf = io.BytesIO() - self._get_unsigned_bytes(buf) - sig = private_key.sign(buf.getvalue()[1:]) + writer = Writer() + self._get_unsigned_bytes(writer) + sig = private_key.sign(writer.bytes()[1:]) pub = private_key.public_key() - sig.encode(buf) - pub.encode(buf) + sig.encode(writer) + pub.encode(writer) self.public_key = pub self.signature = sig self.flags |= FLAG_NOT_SIGNED - return buf.getvalue() + return writer.bytes() diff --git a/pactus/types/amount.py b/pactus/types/amount.py index 662d9c7..aad5120 100644 --- a/pactus/types/amount.py +++ b/pactus/types/amount.py @@ -1,13 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import io - import math -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer NANO_PAC_PER_PAC = 1e9 MAX_NANO_PAC = 42e6 * NANO_PAC_PER_PAC @@ -86,13 +81,13 @@ def from_string(cls, s: str) -> Amount: return cls.from_pac(f) - def encode(self, buf: io.Writer) -> None: - encoding.append_var_int(buf, self.value) + def encode(self, writer: Writer) -> None: + writer.write_var_int(self.value) @classmethod - def decode(cls, buf: io.BufferedReader) -> Amount: + def decode(cls, reader: Reader) -> Amount: """Decode an Amount from the stream.""" - val = encoding.read_var_int(buf) + val = reader.read_var_int() return cls(val) @staticmethod diff --git a/pactus/types/height.py b/pactus/types/height.py index f2e6b10..3c5713c 100644 --- a/pactus/types/height.py +++ b/pactus/types/height.py @@ -1,11 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import io - -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer class Height: @@ -31,11 +26,11 @@ def __hash__(self) -> int: def __str__(self) -> str: return str(self.value) - def encode(self, buf: io.Writer) -> None: - encoding.append_uint32(buf, self.value) + def encode(self, writer: Writer) -> None: + writer.write_uint32(self.value) @classmethod - def decode(cls, buf: io.BufferedReader) -> Height: + def decode(cls, reader: Reader) -> Height: """Decode a Height from the stream.""" - val = encoding.read_uint32(buf) + val = reader.read_uint32() return cls(val) diff --git a/pactus/types/round.py b/pactus/types/round.py index 3be4c25..ca4a739 100644 --- a/pactus/types/round.py +++ b/pactus/types/round.py @@ -1,11 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import io - -from pactus.encoding import encoding +from pactus.encoding import Reader, Writer class Round: @@ -30,11 +25,11 @@ def __hash__(self) -> int: def __str__(self) -> str: return str(self.value) - def encode(self, buf: io.Writer) -> None: - encoding.append_uint16(buf, self.value) + def encode(self, writer: Writer) -> None: + writer.write_uint16(self.value) @classmethod - def decode(cls, buf: io.BufferedReader) -> Round: + def decode(cls, reader: Reader) -> Round: """Decode a Round from the stream.""" - val = encoding.read_uint16(buf) + val = reader.read_uint16() return cls(val) diff --git a/tests/test_amount.py b/tests/test_amount.py index 476233f..a2149e6 100644 --- a/tests/test_amount.py +++ b/tests/test_amount.py @@ -1,6 +1,6 @@ -import io import unittest +from pactus.encoding import Reader, Writer from pactus.types.amount import NANO_PAC_PER_PAC, Amount @@ -235,11 +235,11 @@ def test_encode_decode(self): ] for original in test_cases: - buf = io.BytesIO() - original.encode(buf) + writer = Writer() + original.encode(writer) - buf.seek(0) - decoded = Amount.decode(buf) + reader = Reader(writer.bytes()) + decoded = Amount.decode(reader) self.assertEqual(decoded, original) def test_encode_known_values(self): @@ -253,9 +253,9 @@ def test_encode_known_values(self): ] for amount, expected_bytes in test_cases: - buf = io.BytesIO() - amount.encode(buf) - self.assertEqual(buf.getvalue(), expected_bytes) + writer = Writer() + amount.encode(writer) + self.assertEqual(writer.bytes(), expected_bytes) if __name__ == "__main__": diff --git a/tests/test_block.py b/tests/test_block.py index 63b7834..fb55206 100644 --- a/tests/test_block.py +++ b/tests/test_block.py @@ -1,8 +1,8 @@ -import io import unittest from pactus.block import Block from pactus.crypto.address import AddressType +from pactus.encoding import Reader from pactus.transaction.payload import PayloadType @@ -35,7 +35,7 @@ def test_decode_block_88888(self): ) raw = bytes.fromhex(raw_hex) - block = Block.decode(io.BytesIO(raw)) + block = Block.decode(Reader(raw)) # --- Header --- h = block.header diff --git a/tests/test_encoding.py b/tests/test_encoding.py index 53eb221..bf38632 100644 --- a/tests/test_encoding.py +++ b/tests/test_encoding.py @@ -1,18 +1,6 @@ import unittest -import io - -from pactus.encoding.encoding import ( - append_uint8, - append_uint16, - append_uint32, - append_str, - append_var_int, - read_uint8, - read_uint16, - read_uint32, - read_str, - read_var_int, -) + +from pactus.encoding import Reader, Writer class TestEncoding(unittest.TestCase): @@ -24,12 +12,12 @@ def test_uint8(self): ] for _, (value, expected_bytes) in enumerate(tests): - buf = io.BytesIO() - append_uint8(buf, value) - self.assertEqual(buf.getvalue(), expected_bytes) + writer = Writer() + writer.write_uint8(value) + self.assertEqual(writer.bytes(), expected_bytes) - buf.seek(0) - read = read_uint8(buf) + reader = Reader(writer.bytes()) + read = reader.read_uint8() self.assertEqual(read, value) def test_uint16(self): @@ -42,12 +30,12 @@ def test_uint16(self): ] for _, (value, expected_bytes) in enumerate(tests): - buf = io.BytesIO() - append_uint16(buf, value) - self.assertEqual(buf.getvalue(), expected_bytes) + writer = Writer() + writer.write_uint16(value) + self.assertEqual(writer.bytes(), expected_bytes) - buf.seek(0) - read = read_uint16(buf) + reader = Reader(writer.bytes()) + read = reader.read_uint16() self.assertEqual(read, value) def test_uint32(self): @@ -60,12 +48,12 @@ def test_uint32(self): ] for _, (value, expected_bytes) in enumerate(tests): - buf = io.BytesIO() - append_uint32(buf, value) - self.assertEqual(buf.getvalue(), expected_bytes) + writer = Writer() + writer.write_uint32(value) + self.assertEqual(writer.bytes(), expected_bytes) - buf.seek(0) - read = read_uint32(buf) + reader = Reader(writer.bytes()) + read = reader.read_uint32() self.assertEqual(read, value) def test_var_int(self): @@ -90,12 +78,12 @@ def test_var_int(self): ] for i, (value, expected_bytes) in enumerate(tests): - buf = io.BytesIO() - append_var_int(buf, value) - self.assertEqual(buf.getvalue(), expected_bytes) + writer = Writer() + writer.write_var_int(value) + self.assertEqual(writer.bytes(), expected_bytes) - buf.seek(0) - read = read_var_int(buf) + reader = Reader(writer.bytes()) + read = reader.read_var_int() self.assertEqual(read, value) def test_str(self): @@ -109,12 +97,12 @@ def test_str(self): ] for i, (value, expected_bytes) in enumerate(tests): - buf = io.BytesIO() - append_str(buf, value) - self.assertEqual(buf.getvalue(), expected_bytes) + writer = Writer() + writer.write_str(value) + self.assertEqual(writer.bytes(), expected_bytes) - buf.seek(0) - read = read_str(buf) + reader = Reader(writer.bytes()) + read = reader.read_str() self.assertEqual(read, value) diff --git a/tests/test_transaction.py b/tests/test_transaction.py index bd120e9..7f1688f 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -1,8 +1,8 @@ -import io import unittest from pactus.crypto.address import Address from pactus.crypto.bls import PrivateKey +from pactus.encoding import Reader from pactus.transaction import Transaction from pactus.transaction.payload import PayloadType from pactus.types.amount import Amount @@ -79,7 +79,7 @@ def test_decode_transfer_tx(self): ) raw = bytes.fromhex(signed_data_hex) - tx = Transaction.decode(io.BytesIO(raw)) + tx = Transaction.decode(Reader(raw)) self.assertEqual(tx.version, 1) self.assertEqual(tx.lock_time.value, 2335524) @@ -133,7 +133,7 @@ def test_decode_bond_tx(self): ) raw = bytes.fromhex(signed_data_hex) - tx = Transaction.decode(io.BytesIO(raw)) + tx = Transaction.decode(Reader(raw)) self.assertEqual(tx.version, 1) self.assertEqual(tx.lock_time.value, 2335580) From ca0d636f0841dafc725ce41ddd871aeceef5a363 Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 3 Jul 2026 13:45:12 +0800 Subject: [PATCH 2/5] fix: address logic, varint validation, and test coverage improvements - Simplify is_account_address() to not is_validator_address() - Raise ValueError on negative varint in Writer.write_var_int() - Add Amount.to_string() method, make __str__ delegate to it - Add comprehensive test_address.py test suite (encode/decode round-trip, treasury) - Refactor test_amount.py with centralized test cases and explicit expected bytes --- pactus/crypto/address.py | 7 +- pactus/encoding/encoding.py | 3 + pactus/types/amount.py | 3 + tests/test_address.py | 74 ++++++++++ tests/test_amount.py | 276 +++++++----------------------------- 5 files changed, 135 insertions(+), 228 deletions(-) create mode 100644 tests/test_address.py diff --git a/pactus/crypto/address.py b/pactus/crypto/address.py index 7a3c478..a7fc027 100644 --- a/pactus/crypto/address.py +++ b/pactus/crypto/address.py @@ -76,12 +76,7 @@ def is_treasury_address(self) -> bool: return self.address_type() == AddressType.TREASURY def is_account_address(self) -> bool: - t = self.address_type() - return t in ( - AddressType.TREASURY, - AddressType.BLS_ACCOUNT, - AddressType.ED25519_ACCOUNT, - ) + return not self.is_validator_address() def is_validator_address(self) -> bool: return self.address_type() == AddressType.VALIDATOR diff --git a/pactus/encoding/encoding.py b/pactus/encoding/encoding.py index 292c956..0163576 100644 --- a/pactus/encoding/encoding.py +++ b/pactus/encoding/encoding.py @@ -19,6 +19,9 @@ def write_uint32(self, val: int) -> None: self._buf.write(val.to_bytes(4, "little")) def write_var_int(self, val: int) -> None: + if val < 0: + raise ValueError("Negative values cannot be encoded as varint") + while val >= 0x80: n = (val & 0x7F) | 0x80 self._buf.write(bytes([n])) diff --git a/pactus/types/amount.py b/pactus/types/amount.py index aad5120..f5abb7f 100644 --- a/pactus/types/amount.py +++ b/pactus/types/amount.py @@ -34,6 +34,9 @@ def __hash__(self) -> int: return hash(self.value) def __str__(self) -> str: + return self.to_string() + + def to_string(self) -> str: """Return a string representation of the amount in PAC.""" pac_value = self.value / NANO_PAC_PER_PAC return f"{pac_value}" diff --git a/tests/test_address.py b/tests/test_address.py new file mode 100644 index 0000000..93b0481 --- /dev/null +++ b/tests/test_address.py @@ -0,0 +1,74 @@ +import unittest + +from pactus.encoding import Reader, Writer +from pactus.crypto import Address + + +class TestAddress(unittest.TestCase): + test_cases = [ + { + "str": "pc1pcs9ezsmn6n7fc8jxzxks4n2lyw4ltzsdc9v8qn", + "hex": "01c40b914373d4fc9c1e4611ad0acd5f23abf58a0d", + }, + { + "str": "pc1zcs9ezsmn6n7fc8jxzxks4n2lyw4ltzsd9wu6hw", + "hex": "02c40b914373d4fc9c1e4611ad0acd5f23abf58a0d", + }, + + { + "str": "pc1rj65g93q7lpdq0366vst22l7va9d26j3l2vr0em", + "hex": "0396a882c41ef85a07c75a6416a57fcce95aad4a3f", + }, + + { + "str": "pc1y90qakls8jlz9hyvdcsqsj0yj2lrqz26vqu7l0z", + "hex": "042bc1db7e0797c45b918dc401093c9257c6012b4c", + }, + + ] + + def test_invalid(self): + test_cases = [ + # Invalid cases + {"input": "pc1p0hrct7eflrpw4ccrttxzs4qud2axex4dg8xaf5"}, + {"input": ""}, + ] + + for case in test_cases: + with self.assertRaises(ValueError): + Address.from_string(case["input"]) + + def test_string(self): + for case in TestAddress.test_cases: + addr = Address.from_string(case["str"]) + self.assertEqual(addr.string(), case["str"]) + self.assertEqual(addr.raw_bytes().hex(), case["hex"]) + + def test_encode_decode(self): + for case in TestAddress.test_cases: + addr = Address.from_string(case["str"]) + + writer = Writer() + + addr.encode(writer) + encoded = writer.bytes() + + self.assertEqual(encoded, bytes.fromhex(case["hex"])) + + reader = Reader(encoded) + decoded = Address.decode(reader) + + self.assertEqual(decoded.string(), addr.string()) + + def test_treasury_address(self): + addr = Address.from_string("000000000000000000000000000000000000000000") + + writer = Writer() + addr.encode(writer) + encoded = writer.bytes() + + self.assertEqual(addr.raw_bytes().hex(), "000000000000000000000000000000000000000000") + self.assertEqual(encoded, bytes.fromhex("00")) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amount.py b/tests/test_amount.py index a2149e6..5f85b83 100644 --- a/tests/test_amount.py +++ b/tests/test_amount.py @@ -5,258 +5,90 @@ class TestAmount(unittest.TestCase): - def test_from_pac(self): - test_cases = [ - # Valid cases - { - "input": 42.5, - "expected": Amount(42.5 * NANO_PAC_PER_PAC), - "raises": None, - }, - {"input": 0.0, "expected": Amount(0 * NANO_PAC_PER_PAC), "raises": None}, - { - "input": -10.5, - "expected": Amount(-10.5 * NANO_PAC_PER_PAC), - "raises": None, - }, - # Invalid cases - {"input": float("nan"), "expected": None, "raises": ValueError}, - {"input": float("inf"), "expected": None, "raises": ValueError}, - {"input": float("-inf"), "expected": None, "raises": ValueError}, - ] - - for case in test_cases: - if case["raises"]: - with self.assertRaises(case["raises"]): - Amount.from_pac(case["input"]) - else: - amt = Amount.from_pac(case["input"]) - self.assertEqual(amt, case["expected"]) - - def test_from_string(self): - test_cases = [ - # Valid cases - { - "input": "42.5", - "expected": Amount(42.5 * NANO_PAC_PER_PAC), - "raises": None, - }, - {"input": "0.0", "expected": Amount(0 * NANO_PAC_PER_PAC), "raises": None}, - { - "input": "-10.5", - "expected": Amount(-10.5 * NANO_PAC_PER_PAC), - "raises": None, - }, - # Invalid cases - {"input": "invalid_string", "expected": None, "raises": ValueError}, - {"input": "nan", "expected": None, "raises": ValueError}, - {"input": "inf", "expected": None, "raises": ValueError}, - {"input": "-inf", "expected": None, "raises": ValueError}, - ] - - for case in test_cases: - if case["raises"]: - with self.assertRaises(case["raises"]): - Amount.from_string(case["input"]) - else: - amt = Amount.from_string(case["input"]) - self.assertEqual(amt, case["expected"]) - - def test_str(self): - test_cases = [ + test_cases = [ { - "input": Amount(0), - "expected": "0.0", + "pac_value": 0, + "nano_pac_value": 0, + "str_value": "0.0", + "bytes_value": b"\x00", }, { - "input": Amount.from_pac(42.5), - "expected": "42.5", + "pac_value": 42.5, + "nano_pac_value": 42.5 * NANO_PAC_PER_PAC, + "str_value": "42.5", + "bytes_value": b"\x80\x92\xca\xa9\x9e\x01", }, { - "input": Amount.from_pac(1.0), - "expected": "1.0", + "pac_value": 1.0, + "nano_pac_value": 1.0 * NANO_PAC_PER_PAC, + "str_value": "1.0", + "bytes_value": b"\x80\x94\xeb\xdc\x03", }, { - "input": Amount.from_pac(0.5), - "expected": "0.5", + "pac_value": 0.5, + "nano_pac_value": 0.5 * NANO_PAC_PER_PAC, + "str_value": "0.5", + "bytes_value": b"\x80\xca\xb5\xee\x01", }, { - "input": Amount.from_pac(1000000.0), - "expected": "1000000.0", + "pac_value": 1_000_000.0, + "nano_pac_value": 1000000.0 * NANO_PAC_PER_PAC, + "str_value": "1000000.0", + "bytes_value": b"\x80\x80\x9a\xa6\xea\xaf\xe3\x01", }, { - "input": Amount.from_pac(0.000000001), - "expected": "1e-09", + "pac_value": 42_000_000, + "nano_pac_value": 42_000_000 * NANO_PAC_PER_PAC, + "str_value": "42000000.0", + "bytes_value": b"\x80\x80\xc4\xc4\xf0\xd8\xcd\x4a", }, { - "input": Amount.from_pac(-10.5), - "expected": "-10.5", - }, - { - "input": Amount.from_nano_pac(1000000000), - "expected": "1.0", - }, - { - "input": Amount.from_nano_pac(500000000), - "expected": "0.5", + "pac_value": 0.000_000_001, + "nano_pac_value": 1, + "str_value": "1e-09", + "bytes_value": b"\x01", }, ] - for case in test_cases: - result = str(case["input"]) - self.assertEqual(result, case["expected"]) - - def test_to_pac(self): + def test_invalid(self): test_cases = [ - { - "input": Amount(0), - "expected": 0.0, - }, - { - "input": Amount.from_pac(42.5), - "expected": 42.5, - }, - { - "input": Amount.from_pac(1.0), - "expected": 1.0, - }, - { - "input": Amount.from_pac(0.5), - "expected": 0.5, - }, - { - "input": Amount.from_pac(1000000.0), - "expected": 1000000.0, - }, - { - "input": Amount.from_pac(0.000000001), - "expected": 0.000000001, - }, - { - "input": Amount.from_pac(-10.5), - "expected": -10.5, - }, - { - "input": Amount.from_nano_pac(1000000000), - "expected": 1.0, - }, - { - "input": Amount.from_nano_pac(500000000), - "expected": 0.5, - }, - { - "input": Amount.from_nano_pac(1), - "expected": 0.000000001, - }, - { - "input": Amount.from_nano_pac(123456789), - "expected": 0.123456789, - }, - ] - - for case in test_cases: - result = case["input"].to_pac() - self.assertAlmostEqual(result, case["expected"], places=9) - - def test_to_nano_pac(self): - test_cases = [ - { - "input": Amount(0), - "expected": 0, - }, - { - "input": Amount.from_pac(42.5), - "expected": int(42.5 * NANO_PAC_PER_PAC), - }, - { - "input": Amount.from_pac(1.0), - "expected": int(1.0 * NANO_PAC_PER_PAC), - }, - { - "input": Amount.from_pac(0.5), - "expected": int(0.5 * NANO_PAC_PER_PAC), - }, - { - "input": Amount.from_pac(1000000.0), - "expected": int(1000000.0 * NANO_PAC_PER_PAC), - }, - { - "input": Amount.from_pac(-10.5), - "expected": int(-10.5 * NANO_PAC_PER_PAC), - }, - { - "input": Amount.from_nano_pac(1000000000), - "expected": 1000000000, - }, - { - "input": Amount.from_nano_pac(500000000), - "expected": 500000000, - }, - { - "input": Amount.from_nano_pac(1), - "expected": 1, - }, - { - "input": Amount.from_nano_pac(123456789), - "expected": 123456789, - }, + # Invalid cases + {"input": float("nan")}, + {"input": float("inf")}, + {"input": float("-inf")}, ] for case in test_cases: - result = case["input"].to_nano_pac() - self.assertEqual(result, case["expected"]) - - def test_round_trip_conversion(self): - """Test that converting from PAC to NanoPAC and back preserves the value.""" - test_values = [0.0, 1.0, 42.5, 0.5, 1000000.0, 0.000000001, -10.5] + with self.assertRaises(ValueError): + Amount.from_pac(case["input"]) - for pac_value in test_values: - amount = Amount.from_pac(pac_value) - nano_pac = amount.to_nano_pac() - back_to_pac = amount.to_pac() + def test_string(self): + for case in TestAmount.test_cases: + amt = Amount.from_string(case["str_value"]) + self.assertEqual(amt.to_nano_pac(), case["nano_pac_value"]) + self.assertEqual(amt.to_string(), case["str_value"]) - # Create new amount from nano_pac and verify - amount_from_nano = Amount.from_nano_pac(nano_pac) - self.assertEqual(amount, amount_from_nano) - self.assertAlmostEqual(back_to_pac, pac_value, places=9) + def test_conversion(self): + for case in TestAmount.test_cases: + amt = Amount.from_pac(case["pac_value"]) + self.assertEqual(amt.to_pac(), case["pac_value"]) + self.assertEqual(amt.to_nano_pac(), case["nano_pac_value"]) def test_encode_decode(self): - """Test that encoding and decoding an Amount round-trips correctly.""" - test_cases = [ - Amount(0), - Amount.from_pac(42.5), - Amount.from_pac(1.0), - Amount.from_pac(0.5), - Amount.from_pac(0.000000001), - Amount.from_nano_pac(1000000000), - Amount.from_nano_pac(500000000), - Amount.from_nano_pac(1), - Amount.from_nano_pac(123456789), - ] + for case in TestAmount.test_cases: + amt = Amount.from_pac(case["pac_value"]) - for original in test_cases: writer = Writer() - original.encode(writer) - reader = Reader(writer.bytes()) - decoded = Amount.decode(reader) - self.assertEqual(decoded, original) + amt.encode(writer) + encoded = writer.bytes() - def test_encode_known_values(self): - """Test that encoding produces expected byte sequences.""" - test_cases = [ - (Amount(0), b"\x00"), - (Amount(1), b"\x01"), - (Amount(0xFF), b"\xff\x01"), - (Amount.from_nano_pac(1000000000), b"\x80\x94\xeb\xdc\x03"), - (Amount.from_nano_pac(42_000_000_000), b"\x80\xc8\x94\xbb\x9c\x01"), - ] + self.assertEqual(encoded, case["bytes_value"]) - for amount, expected_bytes in test_cases: - writer = Writer() - amount.encode(writer) - self.assertEqual(writer.bytes(), expected_bytes) + reader = Reader(encoded) + decoded = Amount.decode(reader) + self.assertEqual(decoded.to_nano_pac(), amt.to_nano_pac()) if __name__ == "__main__": unittest.main() From e8655a8618c818250a38c39e7996249300f13b1b Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 3 Jul 2026 13:48:36 +0800 Subject: [PATCH 3/5] test: add more tests --- tests/test_address.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_address.py b/tests/test_address.py index 93b0481..369d939 100644 --- a/tests/test_address.py +++ b/tests/test_address.py @@ -69,6 +69,7 @@ def test_treasury_address(self): self.assertEqual(addr.raw_bytes().hex(), "000000000000000000000000000000000000000000") self.assertEqual(encoded, bytes.fromhex("00")) + self.assertTrue(addr.is_treasury_address()) if __name__ == "__main__": unittest.main() From aab0a2b82428edb2991eac76236c94af34978246 Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 3 Jul 2026 13:50:10 +0800 Subject: [PATCH 4/5] fix: resolve ruff TRY003/EM101 by assigning error message to variable --- pactus/encoding/encoding.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pactus/encoding/encoding.py b/pactus/encoding/encoding.py index 0163576..8e63baa 100644 --- a/pactus/encoding/encoding.py +++ b/pactus/encoding/encoding.py @@ -20,7 +20,8 @@ def write_uint32(self, val: int) -> None: def write_var_int(self, val: int) -> None: if val < 0: - raise ValueError("Negative values cannot be encoded as varint") + msg = "Negative values cannot be encoded as varint" + raise ValueError(msg) while val >= 0x80: n = (val & 0x7F) | 0x80 From add0458e64b1cadcf47b351dc3b0e26a138baf69 Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 3 Jul 2026 13:50:43 +0800 Subject: [PATCH 5/5] chore: fix linting issues --- tests/test_address.py | 42 ++++++++++----------- tests/test_amount.py | 87 ++++++++++++++++++++++--------------------- 2 files changed, 65 insertions(+), 64 deletions(-) diff --git a/tests/test_address.py b/tests/test_address.py index 369d939..baf3347 100644 --- a/tests/test_address.py +++ b/tests/test_address.py @@ -6,26 +6,23 @@ class TestAddress(unittest.TestCase): test_cases = [ - { - "str": "pc1pcs9ezsmn6n7fc8jxzxks4n2lyw4ltzsdc9v8qn", - "hex": "01c40b914373d4fc9c1e4611ad0acd5f23abf58a0d", - }, - { - "str": "pc1zcs9ezsmn6n7fc8jxzxks4n2lyw4ltzsd9wu6hw", - "hex": "02c40b914373d4fc9c1e4611ad0acd5f23abf58a0d", - }, - - { - "str": "pc1rj65g93q7lpdq0366vst22l7va9d26j3l2vr0em", - "hex": "0396a882c41ef85a07c75a6416a57fcce95aad4a3f", - }, - - { - "str": "pc1y90qakls8jlz9hyvdcsqsj0yj2lrqz26vqu7l0z", - "hex": "042bc1db7e0797c45b918dc401093c9257c6012b4c", - }, - - ] + { + "str": "pc1pcs9ezsmn6n7fc8jxzxks4n2lyw4ltzsdc9v8qn", + "hex": "01c40b914373d4fc9c1e4611ad0acd5f23abf58a0d", + }, + { + "str": "pc1zcs9ezsmn6n7fc8jxzxks4n2lyw4ltzsd9wu6hw", + "hex": "02c40b914373d4fc9c1e4611ad0acd5f23abf58a0d", + }, + { + "str": "pc1rj65g93q7lpdq0366vst22l7va9d26j3l2vr0em", + "hex": "0396a882c41ef85a07c75a6416a57fcce95aad4a3f", + }, + { + "str": "pc1y90qakls8jlz9hyvdcsqsj0yj2lrqz26vqu7l0z", + "hex": "042bc1db7e0797c45b918dc401093c9257c6012b4c", + }, + ] def test_invalid(self): test_cases = [ @@ -67,9 +64,12 @@ def test_treasury_address(self): addr.encode(writer) encoded = writer.bytes() - self.assertEqual(addr.raw_bytes().hex(), "000000000000000000000000000000000000000000") + self.assertEqual( + addr.raw_bytes().hex(), "000000000000000000000000000000000000000000" + ) self.assertEqual(encoded, bytes.fromhex("00")) self.assertTrue(addr.is_treasury_address()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_amount.py b/tests/test_amount.py index 5f85b83..fabec52 100644 --- a/tests/test_amount.py +++ b/tests/test_amount.py @@ -6,49 +6,49 @@ class TestAmount(unittest.TestCase): test_cases = [ - { - "pac_value": 0, - "nano_pac_value": 0, - "str_value": "0.0", - "bytes_value": b"\x00", - }, - { - "pac_value": 42.5, - "nano_pac_value": 42.5 * NANO_PAC_PER_PAC, - "str_value": "42.5", - "bytes_value": b"\x80\x92\xca\xa9\x9e\x01", - }, - { - "pac_value": 1.0, - "nano_pac_value": 1.0 * NANO_PAC_PER_PAC, - "str_value": "1.0", - "bytes_value": b"\x80\x94\xeb\xdc\x03", - }, - { - "pac_value": 0.5, - "nano_pac_value": 0.5 * NANO_PAC_PER_PAC, - "str_value": "0.5", - "bytes_value": b"\x80\xca\xb5\xee\x01", - }, - { - "pac_value": 1_000_000.0, - "nano_pac_value": 1000000.0 * NANO_PAC_PER_PAC, - "str_value": "1000000.0", - "bytes_value": b"\x80\x80\x9a\xa6\xea\xaf\xe3\x01", - }, - { - "pac_value": 42_000_000, - "nano_pac_value": 42_000_000 * NANO_PAC_PER_PAC, - "str_value": "42000000.0", - "bytes_value": b"\x80\x80\xc4\xc4\xf0\xd8\xcd\x4a", - }, - { - "pac_value": 0.000_000_001, - "nano_pac_value": 1, - "str_value": "1e-09", - "bytes_value": b"\x01", - }, - ] + { + "pac_value": 0, + "nano_pac_value": 0, + "str_value": "0.0", + "bytes_value": b"\x00", + }, + { + "pac_value": 42.5, + "nano_pac_value": 42.5 * NANO_PAC_PER_PAC, + "str_value": "42.5", + "bytes_value": b"\x80\x92\xca\xa9\x9e\x01", + }, + { + "pac_value": 1.0, + "nano_pac_value": 1.0 * NANO_PAC_PER_PAC, + "str_value": "1.0", + "bytes_value": b"\x80\x94\xeb\xdc\x03", + }, + { + "pac_value": 0.5, + "nano_pac_value": 0.5 * NANO_PAC_PER_PAC, + "str_value": "0.5", + "bytes_value": b"\x80\xca\xb5\xee\x01", + }, + { + "pac_value": 1_000_000.0, + "nano_pac_value": 1000000.0 * NANO_PAC_PER_PAC, + "str_value": "1000000.0", + "bytes_value": b"\x80\x80\x9a\xa6\xea\xaf\xe3\x01", + }, + { + "pac_value": 42_000_000, + "nano_pac_value": 42_000_000 * NANO_PAC_PER_PAC, + "str_value": "42000000.0", + "bytes_value": b"\x80\x80\xc4\xc4\xf0\xd8\xcd\x4a", + }, + { + "pac_value": 0.000_000_001, + "nano_pac_value": 1, + "str_value": "1e-09", + "bytes_value": b"\x01", + }, + ] def test_invalid(self): test_cases = [ @@ -90,5 +90,6 @@ def test_encode_decode(self): self.assertEqual(decoded.to_nano_pac(), amt.to_nano_pac()) + if __name__ == "__main__": unittest.main()