mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-07-02 21:29:47 +00:00
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:
commit
e38007d13e
@ -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]:
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
]
|
||||
|
||||
@ -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__}.")
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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__}")
|
||||
|
||||
|
||||
@ -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
37
tests/fixtures/block_new_format.json
vendored
Normal 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
78
tests/fixtures/ops_samples_testnet.json
vendored
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
145
tests/test_node_serializers.py
Normal file
145
tests/test_node_serializers.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user