diff --git a/src/api/v1/health.py b/src/api/v1/health.py index a00c5df..fb873fa 100644 --- a/src/api/v1/health.py +++ b/src/api/v1/health.py @@ -12,7 +12,8 @@ from node.api.serializers.health import HealthSerializer async def get(request: NBERequest) -> Response: response = await request.app.state.node_api.get_health() - return JSONResponse(response) + # JSONResponse can't render a pydantic model directly; dump it first. + return JSONResponse(response.model_dump(mode="json")) async def _create_health_stream(node_api: NodeApi, *, poll_interval_seconds: int = 10) -> AsyncIterator[Health]: diff --git a/src/models/transactions/operations/contents.py b/src/models/transactions/operations/contents.py index 0aa4932..6cbe04b 100644 --- a/src/models/transactions/operations/contents.py +++ b/src/models/transactions/operations/contents.py @@ -48,7 +48,9 @@ class ChannelBlob(NbeContent): class ChannelSetKeys(NbeContent): type: Literal["ChannelSetKeys"] = "ChannelSetKeys" channel: HexBytes - keys: List[bytes] + # HexBytes (not plain bytes): content is stored as JSON in the DB, and raw + # bytes break its utf-8 encoding for arbitrary key material. + keys: List[HexBytes] class SDPDeclareServiceType(Enum): @@ -85,6 +87,26 @@ class LeaderClaim(NbeContent): mantle_tx_hash: HexBytes +class UnknownOp(NbeContent): + """Fallback for mantle ops without a typed model (same approach as #19). + + Preserves the opcode and raw payload verbatim so new node op types never + break block ingestion; typed support can be added later. + """ + + type: Literal["UnknownOp"] = "UnknownOp" + opcode: int + raw_payload: Optional[Any] = None + + OperationContent = ( - LedgerTransfer | ChannelInscribe | ChannelBlob | ChannelSetKeys | SDPDeclare | SDPWithdraw | SDPActive | LeaderClaim + LedgerTransfer + | ChannelInscribe + | ChannelBlob + | ChannelSetKeys + | SDPDeclare + | SDPWithdraw + | SDPActive + | LeaderClaim + | UnknownOp ) diff --git a/src/models/transactions/operations/proofs.py b/src/models/transactions/operations/proofs.py index a27b145..029bcd3 100644 --- a/src/models/transactions/operations/proofs.py +++ b/src/models/transactions/operations/proofs.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Literal +from typing import Any, Literal, Optional from core.models import NbeSchema from core.types import HexBytes @@ -31,4 +31,15 @@ class ZkAndEd25519Signature(NbeSignature): ed25519_signature: HexBytes -OperationProof = Ed25519Signature | ZkSignature | ZkAndEd25519Signature +class UnknownSignature(NbeSignature): + """Fallback for proof variants without a typed model (same approach as #19). + + `raw` holds the value verbatim — it covers unit variants like "NoProof" + (which carry no signature bytes) as well as future tagged variants. + """ + + type: Literal["Unknown"] = "Unknown" + raw: Optional[Any] = None + + +OperationProof = Ed25519Signature | ZkSignature | ZkAndEd25519Signature | UnknownSignature diff --git a/src/node/api/http.py b/src/node/api/http.py index e5f8944..dd75c13 100644 --- a/src/node/api/http.py +++ b/src/node/api/http.py @@ -21,6 +21,21 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def normalize_info_payload(data: dict) -> dict: + """Normalize cryptarchia/info responses. + + Current nodes wrap the fields under "cryptarchia_info" and report mode as + a (possibly nested) object like {"Started": "Online"}; flat payloads with a + plain string mode pass through unchanged. + """ + info = dict(data.get("cryptarchia_info", data)) + mode = data.get("mode", info.get("mode")) + while isinstance(mode, dict): + mode = next(iter(mode.values()), "Unknown") if mode else "Unknown" + info["mode"] = mode if isinstance(mode, str) else str(mode) + return info + + class HttpNodeApi(NodeApi): # Paths can't have a leading slash since they are relative to the base URL ENDPOINT_INFO = "cryptarchia/info" @@ -79,7 +94,7 @@ class HttpNodeApi(NodeApi): url = urljoin(self.base_url, self.ENDPOINT_INFO) response = requests.get(url, auth=self.authentication, timeout=60) response.raise_for_status() - return InfoSerializer.model_validate(response.json()) + return InfoSerializer.model_validate(normalize_info_payload(response.json())) async def get_block_by_hash(self, block_hash: str) -> Optional[BlockSerializer]: url = urljoin(self.base_url, self.ENDPOINT_BLOCK_BY_HASH + block_hash) diff --git a/src/node/api/serializers/fields.py b/src/node/api/serializers/fields.py index c687776..894fb95 100644 --- a/src/node/api/serializers/fields.py +++ b/src/node/api/serializers/fields.py @@ -28,6 +28,17 @@ def bytes_from_int(data: int) -> bytes: return data.to_bytes((data.bit_length() + 7) // 8) # TODO: Ensure endianness is correct. +def bytes_from_hex_or_intarray(data: str | list[int]) -> bytes: + """Node versions drifted between int arrays and hex strings for byte fields. + + Older nodes (<= 0.1.2) serialize bytes as int arrays; newer nodes serialize + them as hex strings. Accept both so the explorer works across node versions. + """ + if isinstance(data, str): + return bytes_from_hex(data) + return bytes_from_intarray(data) + + def bytes_into_hex(data: bytes) -> str: return data.hex() @@ -35,3 +46,6 @@ def bytes_into_hex(data: bytes) -> str: BytesFromIntArray = Annotated[bytes, BeforeValidator(bytes_from_intarray), PlainSerializer(bytes_into_hex)] BytesFromHex = Annotated[bytes, BeforeValidator(bytes_from_hex), PlainSerializer(bytes_into_hex)] BytesFromInt = Annotated[bytes, BeforeValidator(bytes_from_int), PlainSerializer(bytes_into_hex)] +BytesFromHexOrIntArray = Annotated[ + bytes, BeforeValidator(bytes_from_hex_or_intarray), PlainSerializer(bytes_into_hex) +] diff --git a/src/node/api/serializers/operation.py b/src/node/api/serializers/operation.py index e5c518b..447ba5a 100644 --- a/src/node/api/serializers/operation.py +++ b/src/node/api/serializers/operation.py @@ -1,3 +1,4 @@ +import logging from random import randint from typing import Annotated, Any, List, Optional, Self, Union @@ -5,13 +6,16 @@ from pydantic import BeforeValidator, Field from core.models import NbeSerializer from models.transactions.operations.contents import SDPDeclareServiceType -from node.api.serializers.fields import BytesFromHex, BytesFromIntArray +from node.api.serializers.fields import BytesFromHex, BytesFromHexOrIntArray from node.api.serializers.note import NoteSerializer from utils.protocols import FromRandom from utils.random import random_bytes +logger = logging.getLogger(__name__) + # Mantle op opcodes. OPCODE_LEDGER = 0 +OPCODE_CHANNEL_SET_KEYS = 16 OPCODE_CHANNEL_INSCRIBE = 17 OPCODE_SDP_DECLARE = 32 OPCODE_SDP_ACTIVE = 34 @@ -35,11 +39,29 @@ class LedgerOpSerializer(NbeSerializer, FromRandom): ) +class ChannelSetKeysOpSerializer(NbeSerializer, FromRandom): + """Channel set-keys op (opcode 16): sets the signing keys of a channel.""" + + channel: BytesFromHexOrIntArray = Field(description="Channel ID.") + keys: List[BytesFromHexOrIntArray] = Field(description="Channel signing public keys.") + + @classmethod + def from_random(cls) -> Self: + return cls.model_validate( + { + "channel": random_bytes(32).hex(), + "keys": [random_bytes(32).hex()], + } + ) + + class ChannelInscribeOpSerializer(NbeSerializer, FromRandom): """Channel inscribe op (opcode 17): writes an inscription to a channel.""" channel_id: BytesFromHex = Field(description="Channel ID in hex format.") - inscription: BytesFromIntArray = Field(description="Inscription bytes (int array).") + inscription: BytesFromHexOrIntArray = Field( + description="Inscription bytes (int array on older nodes, hex string on newer ones)." + ) parent: BytesFromHex = Field(description="Parent inscription hash in hex format.") signer: BytesFromHex = Field(description="Signer public key in hex format.") @@ -98,8 +120,20 @@ class SDPActiveOpSerializer(NbeSerializer, FromRandom): ) +class UnknownOpSerializer(NbeSerializer): + """Fallback for opcodes without a typed serializer. + + Preserves the opcode and raw payload verbatim so unknown (e.g. newly + introduced) op types never break block ingestion. + """ + + opcode: int + payload: Optional[Any] = None + + OPCODE_TO_SERIALIZER: dict[int, type] = { OPCODE_LEDGER: LedgerOpSerializer, + OPCODE_CHANNEL_SET_KEYS: ChannelSetKeysOpSerializer, OPCODE_CHANNEL_INSCRIBE: ChannelInscribeOpSerializer, OPCODE_SDP_DECLARE: SDPDeclareOpSerializer, OPCODE_SDP_ACTIVE: SDPActiveOpSerializer, @@ -108,11 +142,13 @@ OPCODE_TO_SERIALIZER: dict[int, type] = { MantleOpSerializerVariants = Union[ LedgerOpSerializer, + ChannelSetKeysOpSerializer, ChannelInscribeOpSerializer, SDPDeclareOpSerializer, SDPActiveOpSerializer, + UnknownOpSerializer, ] -_MANTLE_OP_SERIALIZER_CLASSES = tuple(OPCODE_TO_SERIALIZER.values()) +_MANTLE_OP_SERIALIZER_CLASSES = tuple(OPCODE_TO_SERIALIZER.values()) + (UnknownOpSerializer,) def _parse_mantle_op(data: Any) -> MantleOpSerializerVariants: @@ -122,9 +158,8 @@ def _parse_mantle_op(data: Any) -> MantleOpSerializerVariants: opcode = data["opcode"] serializer_class = OPCODE_TO_SERIALIZER.get(opcode) if serializer_class is None: - raise ValueError( - f"Unsupported mantle op opcode {opcode}; known opcodes: {sorted(OPCODE_TO_SERIALIZER)}." - ) + logger.warning(f"No typed serializer for mantle op opcode {opcode}; storing it verbatim.") + return UnknownOpSerializer.model_validate(data) return serializer_class.model_validate(data["payload"]) raise ValueError(f"Cannot parse mantle op from {type(data).__name__}.") diff --git a/src/node/api/serializers/proof.py b/src/node/api/serializers/proof.py index 3e59928..8f701bd 100644 --- a/src/node/api/serializers/proof.py +++ b/src/node/api/serializers/proof.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Annotated, Any, Self, Union +from typing import Annotated, Any, Optional, Self, Union from pydantic import BeforeValidator, Field, RootModel @@ -7,10 +7,11 @@ from core.models import NbeSerializer from models.transactions.operations.proofs import ( Ed25519Signature, NbeSignature, + UnknownSignature, ZkAndEd25519Signature, ZkSignature, ) -from node.api.serializers.fields import BytesFromHex, BytesFromIntArray +from node.api.serializers.fields import BytesFromHex, BytesFromHexOrIntArray from utils.protocols import EnforceSubclassFromRandom from utils.random import random_bytes @@ -22,7 +23,7 @@ class OperationProofSerializer(EnforceSubclassFromRandom, ABC): class Ed25519SignatureSerializer(OperationProofSerializer, RootModel[bytes]): - root: BytesFromIntArray + root: BytesFromHexOrIntArray def into_operation_proof(self) -> NbeSignature: return Ed25519Signature.model_validate( @@ -39,9 +40,9 @@ class Ed25519SignatureSerializer(OperationProofSerializer, RootModel[bytes]): class ZkSignatureSerializer(OperationProofSerializer, NbeSerializer): """Groth16 ZK proof: pi_a (32B) + pi_b (64B) + pi_c (32B) = 128 bytes total.""" - pi_a: BytesFromIntArray - pi_b: BytesFromIntArray - pi_c: BytesFromIntArray + pi_a: BytesFromHexOrIntArray + pi_b: BytesFromHexOrIntArray + pi_c: BytesFromHexOrIntArray def to_bytes(self) -> bytes: return self.pi_a + self.pi_b + self.pi_c @@ -90,6 +91,23 @@ class ZkAndEd25519SignaturesSerializer(OperationProofSerializer, NbeSerializer): ) +class UnknownProofSerializer(OperationProofSerializer, NbeSerializer): + """Fallback for proof variants without a typed serializer (e.g. NoProof). + + Preserves the raw value verbatim so unknown proof types never break block + ingestion. + """ + + raw: Optional[Any] = None + + def into_operation_proof(self) -> NbeSignature: + return UnknownSignature.model_validate({"raw": self.raw}) + + @classmethod + def from_random(cls, *args, **kwargs) -> Self: + return cls.model_validate({"raw": "NoProof"}) + + PROOF_TAG_TO_SERIALIZER = { "Ed25519Sig": Ed25519SignatureSerializer, "ZkSig": ZkSignatureSerializer, @@ -104,11 +122,13 @@ def _parse_proof(data: Any) -> OperationProofSerializer: for tag, serializer_class in PROOF_TAG_TO_SERIALIZER.items(): if tag in data: return serializer_class.model_validate(data[tag]) - return data + # Unit variants (e.g. "NoProof") arrive as plain strings; unknown tagged + # variants arrive as dicts that matched no known tag. Keep them verbatim. + return UnknownProofSerializer.model_validate({"raw": data}) OperationProofSerializerVariants = Union[ - Ed25519SignatureSerializer, ZkSignatureSerializer, ZkAndEd25519SignaturesSerializer + Ed25519SignatureSerializer, ZkSignatureSerializer, ZkAndEd25519SignaturesSerializer, UnknownProofSerializer ] OperationProofSerializerField = Annotated[ OperationProofSerializerVariants, diff --git a/src/node/api/serializers/signed_transaction.py b/src/node/api/serializers/signed_transaction.py index 89ad9d2..fec8c18 100644 --- a/src/node/api/serializers/signed_transaction.py +++ b/src/node/api/serializers/signed_transaction.py @@ -8,13 +8,16 @@ from core.models import NbeSerializer from models.transactions.transaction import Transaction from node.api.serializers.operation import ( ChannelInscribeOpSerializer, + ChannelSetKeysOpSerializer, LedgerOpSerializer, SDPActiveOpSerializer, SDPDeclareOpSerializer, + UnknownOpSerializer, ) from node.api.serializers.proof import ( Ed25519SignatureSerializer, OperationProofSerializerField, + UnknownProofSerializer, ZkAndEd25519SignaturesSerializer, ZkSignatureSerializer, ) @@ -33,6 +36,8 @@ def _proof_to_internal(proof) -> dict: "zk_signature": proof.zk_signature.to_bytes(), "ed25519_signature": proof.ed25519_signature, } + if isinstance(proof, UnknownProofSerializer): + return {"type": "Unknown", "raw": proof.raw} raise ValueError(f"Unsupported proof type: {type(proof).__name__}") @@ -44,7 +49,12 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom): ) def _compute_hash(self) -> bytes: - data = self.transaction.model_dump(mode="json") + # Prefer the canonical hash reported by the node (newer nodes include it + # in mantle_tx). A locally computed JSON hash will NOT match the chain's + # real tx hash, so it is only a last-resort fallback for older nodes. + if self.transaction.hash is not None: + return self.transaction.hash + data = self.transaction.model_dump(mode="json", exclude={"hash"}) canonical = json.dumps(data, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode()).digest() @@ -116,6 +126,28 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom): "proof": _proof_to_internal(proof), } ) + elif isinstance(op, ChannelSetKeysOpSerializer): + operations.append( + { + "content": { + "type": "ChannelSetKeys", + "channel": op.channel, + "keys": list(op.keys), + }, + "proof": _proof_to_internal(proof), + } + ) + elif isinstance(op, UnknownOpSerializer): + operations.append( + { + "content": { + "type": "UnknownOp", + "opcode": op.opcode, + "raw_payload": op.payload, + }, + "proof": _proof_to_internal(proof), + } + ) else: raise ValueError(f"Unsupported mantle op type: {type(op).__name__}") diff --git a/src/node/api/serializers/transaction.py b/src/node/api/serializers/transaction.py index 3ffab43..0f156dd 100644 --- a/src/node/api/serializers/transaction.py +++ b/src/node/api/serializers/transaction.py @@ -1,17 +1,21 @@ from random import randint -from typing import List, Self +from typing import List, Optional, Self from pydantic import Field from core.models import NbeSerializer +from node.api.serializers.fields import BytesFromHex from node.api.serializers.operation import LedgerOpSerializer, MantleOpSerializerField from utils.protocols import FromRandom class TransactionSerializer(NbeSerializer, FromRandom): ops: List[MantleOpSerializerField] - execution_gas_price: int = Field(description="Integer in u64 format.") - storage_gas_price: int = Field(description="Integer in u64 format.") + # Newer nodes include the canonical tx hash in mantle_tx; older ones don't. + hash: Optional[BytesFromHex] = Field(default=None, description="Canonical tx hash (newer nodes only).") + # Gas prices were dropped from mantle_tx on newer nodes; default to 0 there. + execution_gas_price: int = Field(default=0, description="Integer in u64 format.") + storage_gas_price: int = Field(default=0, description="Integer in u64 format.") @classmethod def from_random(cls) -> Self: diff --git a/tests/fixtures/block_new_format.json b/tests/fixtures/block_new_format.json new file mode 100644 index 0000000..44332da --- /dev/null +++ b/tests/fixtures/block_new_format.json @@ -0,0 +1,37 @@ +{ + "header": { + "id": "d266fec335ecf2bfb713db1c5bf389e21fd80d208a9ef01989da6aef3deae7b4", + "parent_block": "c4afbf0d3a99fbcb624295a6626ad2786ec181e02522467c55489eb72fe81ce1", + "slot": 13240353, + "block_root": "ac4d5d879a87da6b62a1e1c97840a73372cfef8fd8b7b454d5fbef66e21b1365", + "proof_of_leadership": { + "proof": "bf5f85bf2f664ac55bc0116e75d5ba44d36935935ea53df24cc23bdff10b16a87ec8a60cfd8414b29a50e3467f091c00ae7b2ad4edeedfa6c610de50b4051d249c62e8a0b99e99005ba4b1342c524569150bc8cd58a9d31bda86f5a23d7d858e1094b87b9db5866f998e4aea806b9c3c346b945cb10ba2e4b51c22931bbe901a", + "entropy_contribution": "1a5b15d2ef474acf2f1ef7e46e40967f9bd6ccd5bf1877a85350e6294ddad90a", + "leader_key": "a38c8d8d2e560cd21cf9cf856bb7e93fff41fa6a3ab0991945c3761ee7a20d50", + "voucher_cm": "c4ecf502b774020f2a70474720b4de25653fe8ad56853085725dc6c56b00a405" + } + }, + "transactions": [ + { + "mantle_tx": { + "hash": "9e00ebfc1d641adaeecaf0e20c09ffed78a84fd2f36b790b94cf64f758260348", + "ops": [ + { + "opcode": 17, + "payload": { + "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", + "inscription": "44160000000000006508e79b06e19340759182b8c17ee4f490ad8ffea5bf89ad06e6f10e843932af84e707c7cfa10873b5bd312345a6419de751ed433b6cf2fff0f6a1cefb965d422e0910bc9e010000ca2b0f5396f61a85e56f499c16dfe7b9f56ca86e25c5059cc5e685f59b7b1be5e67e9aaae67b3cb667a56e16454d661365e207f9c35453b792ca98abb16702bb0100000000cf8fdba1d3ac5dbd6b28315cfd7eb206c1a8cab5206a1d48ebb3cd26d4d36f68030000002f4c455a2f436c6f636b50726f6772616d4163636f756e742f303030303030312f4c455a2f436c6f636b50726f6772616d4163636f756e742f303030303031302f4c455a2f436c6f636b50726f6772616d4163636f756e742f3030303030353000000000020000002e0910bc9e0100000000000000", + "parent": "77007291cc00a4ad150d79afe4068008ec662dc3f7e1b70358a427cb03ba6de7", + "signer": "7d1726d002f952db5be1a2f534f8b4bafe429c8e56f6a42c8c6f9faa3aa59b06" + } + } + ] + }, + "ops_proofs": [ + { + "Ed25519Sig": "307e8225cc3ccd3ea4a2a0de7509aa90a85453cb9d4fb2f56e1c36f83cd00ebf33805807f45fb6ddb332a1caa1c096ea8a95bb2135a500014174a71a256fa50a" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/ops_samples_testnet.json b/tests/fixtures/ops_samples_testnet.json new file mode 100644 index 0000000..e351dad --- /dev/null +++ b/tests/fixtures/ops_samples_testnet.json @@ -0,0 +1,78 @@ +{ + "16": { + "payload": { + "channel": "d894b6a35644bcb53d9a31be31485b68257368c1d1ee1a0f4a82d85ec63208a5", + "keys": [ + "695c36dc795b084122f596fa7d6abbf35f0f8772d47f01c7ebc294bc0a9d0f8d" + ] + }, + "proof": { + "Ed25519Sig": [ + 38, + 229, + 5, + 199, + 203, + 222, + 82, + 178, + 147, + 99, + 199, + 230, + 185, + 102, + 143, + 138, + 88, + 159, + 171, + 28, + 17, + 21, + 8, + 131, + 137, + 164, + 233, + 118, + 100, + 21, + 214, + 227, + 82, + 10, + 71, + 170, + 26, + 50, + 120, + 127, + 230, + 209, + 122, + 65, + 106, + 95, + 138, + 50, + 181, + 121, + 87, + 48, + 57, + 71, + 41, + 0, + 195, + 122, + 165, + 126, + 104, + 137, + 25, + 0 + ] + } + } +} \ No newline at end of file diff --git a/tests/test_node_serializers.py b/tests/test_node_serializers.py new file mode 100644 index 0000000..9239411 --- /dev/null +++ b/tests/test_node_serializers.py @@ -0,0 +1,145 @@ +"""Tests for node API serializers against the current (0.1.3-rc) wire format. + +The fixtures are real data: block_new_format.json is a block captured from a +node, and ops_samples_testnet.json holds per-opcode op/proof samples harvested +from the chain. +""" + +import json +from pathlib import Path + +import pytest + +from node.api.http import normalize_info_payload +from node.api.serializers.block import BlockSerializer +from node.api.serializers.fields import bytes_from_hex_or_intarray +from node.api.serializers.info import InfoSerializer +from node.api.serializers.operation import ChannelSetKeysOpSerializer, UnknownOpSerializer +from node.api.serializers.proof import Ed25519SignatureSerializer + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def block() -> dict: + return json.loads((FIXTURES / "block_new_format.json").read_text()) + + +class TestInfoNormalization: + def test_nested_format(self): + payload = { + "cryptarchia_info": {"lib": "aa", "lib_slot": 9, "tip": "bb", "slot": 10, "height": 3}, + "mode": {"Started": "Online"}, + } + info = InfoSerializer.model_validate(normalize_info_payload(payload)) + assert info.mode == "Online" + assert info.height == 3 + assert info.tip == "bb" + + def test_flat_format_passes_through(self): + payload = {"lib": "aa", "tip": "bb", "slot": 1, "height": 2, "mode": "Online"} + info = InfoSerializer.model_validate(normalize_info_payload(payload)) + assert info.mode == "Online" + assert info.height == 2 + + +class TestHexOrIntArrayField: + def test_accepts_hex_string(self): + assert bytes_from_hex_or_intarray("68656c6c6f") == b"hello" + + def test_accepts_int_array(self): + assert bytes_from_hex_or_intarray([104, 101, 108, 108, 111]) == b"hello" + + def test_rejects_other_types(self): + with pytest.raises(ValueError): + bytes_from_hex_or_intarray(42) + + +class TestBlockParsing: + def test_parses_real_block(self, block): + parsed = BlockSerializer.model_validate(block) + assert parsed.header.slot == block["header"]["slot"] + assert len(parsed.transactions) == len(block["transactions"]) + + def test_tx_hash_comes_from_node(self, block): + parsed = BlockSerializer.model_validate(block) + tx = parsed.transactions[0].into_transaction() + expected = bytes.fromhex(block["transactions"][0]["mantle_tx"]["hash"]) + assert tx.hash == expected + + def test_inscription_decodes_from_hex(self, block): + parsed = BlockSerializer.model_validate(block) + op = parsed.transactions[0].transaction.ops[0] + raw_hex = block["transactions"][0]["mantle_tx"]["ops"][0]["payload"]["inscription"] + assert op.inscription == bytes.fromhex(raw_hex) + # Sequencer inscriptions reference LEZ program accounts. + assert b"/LEZ/" in op.inscription + + def test_ed25519_proof_from_hex(self, block): + parsed = BlockSerializer.model_validate(block) + proof = parsed.transactions[0].operations_proofs[0] + assert isinstance(proof, Ed25519SignatureSerializer) + assert len(proof.root) == 64 + + def test_into_block_roundtrip(self, block): + parsed = BlockSerializer.model_validate(block).into_block() + assert parsed.hash == bytes.fromhex(block["header"]["id"]) + assert parsed.transactions[0].operations[0].content.type == "ChannelInscribe" + + def test_missing_gas_prices_default_to_zero(self, block): + parsed = BlockSerializer.model_validate(block) + tx = parsed.transactions[0].into_transaction() + assert tx.execution_gas_price == 0 + assert tx.storage_gas_price == 0 + + def test_hash_fallback_when_node_omits_it(self, block): + del block["transactions"][0]["mantle_tx"]["hash"] + parsed = BlockSerializer.model_validate(block) + tx = parsed.transactions[0].into_transaction() + assert len(tx.hash) == 32 # deterministic local fallback + + +class TestUnknownOps: + def test_unknown_opcode_is_preserved_not_fatal(self, block): + block["transactions"][0]["mantle_tx"]["ops"][0]["opcode"] = 99 + parsed = BlockSerializer.model_validate(block) + op = parsed.transactions[0].transaction.ops[0] + assert isinstance(op, UnknownOpSerializer) + assert op.opcode == 99 + content = parsed.transactions[0].into_transaction().operations[0].content + assert content.type == "UnknownOp" + assert content.opcode == 99 + assert content.raw_payload is not None # raw payload preserved verbatim + + def test_unknown_op_with_noproof_is_preserved_not_fatal(self, block): + # e.g. a LeaderClaim carries no proof; neither the op nor the + # "NoProof" unit variant should break ingestion. + tx = block["transactions"][0] + tx["mantle_tx"]["ops"][0] = {"opcode": 48, "payload": {"rewards_root": "aa" * 32}} + tx["ops_proofs"][0] = "NoProof" + parsed = BlockSerializer.model_validate(block) + operation = parsed.transactions[0].into_transaction().operations[0] + assert operation.content.type == "UnknownOp" + assert operation.content.opcode == 48 + assert operation.proof.type == "Unknown" + assert operation.proof.raw == "NoProof" + + +class TestChannelSetKeysOp: + @pytest.fixture + def setkeys_sample(self) -> dict: + """Real opcode 16 op + proof captured from the chain.""" + samples = json.loads((FIXTURES / "ops_samples_testnet.json").read_text()) + return samples["16"] + + def test_real_sample_parses(self, block, setkeys_sample): + tx = block["transactions"][0] + tx["mantle_tx"]["ops"] = [{"opcode": 16, "payload": setkeys_sample["payload"]}] + tx["ops_proofs"] = [setkeys_sample["proof"]] + parsed = BlockSerializer.model_validate(block) + op = parsed.transactions[0].transaction.ops[0] + assert isinstance(op, ChannelSetKeysOpSerializer) + assert op.channel == bytes.fromhex(setkeys_sample["payload"]["channel"]) + assert len(op.keys) == len(setkeys_sample["payload"]["keys"]) + content = parsed.transactions[0].into_transaction().operations[0].content + assert content.type == "ChannelSetKeys"