Merge pull request #20 from kamikazebr/fix/node-api-version-drift

Parse the current node wire format; never halt ingestion on unknown op types
This commit is contained in:
gusto 2026-06-15 14:50:13 +03:00 committed by GitHub
commit e38007d13e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 438 additions and 24 deletions

View File

@ -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]:

View File

@ -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
)

View File

@ -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

View File

@ -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)

View File

@ -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)
]

View File

@ -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__}.")

View File

@ -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,

View File

@ -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__}")

View File

@ -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:

37
tests/fixtures/block_new_format.json vendored Normal file
View File

@ -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"
}
]
}
]
}

78
tests/fixtures/ops_samples_testnet.json vendored Normal file
View File

@ -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
]
}
}
}

View File

@ -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"