From cce312ecb6c9bddf9fbb90c219969a99d396acbc Mon Sep 17 00:00:00 2001 From: Antonio Antonino Date: Fri, 19 Dec 2025 16:48:21 +0100 Subject: [PATCH] Sync block structure with latest state --- src/node/api/serializers/fields.py | 17 +++- src/node/api/serializers/header.py | 23 +++-- src/node/api/serializers/operation.py | 89 +++++++++++++++---- src/node/api/serializers/proof.py | 62 ++++++++----- .../api/serializers/proof_of_leadership.py | 26 ++---- .../api/serializers/signed_transaction.py | 29 ++++-- src/node/api/serializers/transaction.py | 13 ++- 7 files changed, 182 insertions(+), 77 deletions(-) diff --git a/src/node/api/serializers/fields.py b/src/node/api/serializers/fields.py index c687776..502c864 100644 --- a/src/node/api/serializers/fields.py +++ b/src/node/api/serializers/fields.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Union from pydantic import BeforeValidator, PlainSerializer, ValidationError @@ -28,6 +28,20 @@ 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: Union[str, list[int]]) -> bytes: + """Accepts either hex string or int array and converts to bytes.""" + if isinstance(data, str): + return bytes.fromhex(data) + elif isinstance(data, list): + if not all(isinstance(item, int) for item in data): + raise ValueError("List items must be integers.") + return bytes(data) + else: + raise ValueError( + f"Unsupported data type for bytes deserialization. Expected string or list, got {type(data).__name__}." + ) + + def bytes_into_hex(data: bytes) -> str: return data.hex() @@ -35,3 +49,4 @@ 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)] +BytesFlexible = Annotated[bytes, BeforeValidator(bytes_from_hex_or_intarray), PlainSerializer(bytes_into_hex)] \ No newline at end of file diff --git a/src/node/api/serializers/header.py b/src/node/api/serializers/header.py index c7268dc..477de93 100644 --- a/src/node/api/serializers/header.py +++ b/src/node/api/serializers/header.py @@ -1,8 +1,9 @@ +from enum import IntEnum from random import randint -from typing import Self +from typing import Optional, Self -from pydantic import Field -from rusty_results import Option, Some +from pydantic import Field, computed_field +from rusty_results import Option from core.models import NbeSerializer from node.api.serializers.fields import BytesFromHex @@ -14,21 +15,33 @@ from utils.protocols import FromRandom from utils.random import random_hash +class Version(IntEnum): + Bedrock = 1 + + class HeaderSerializer(NbeSerializer, FromRandom): - hash: BytesFromHex = Field(alias="id", description="Hash id in hex format.") + id: Optional[BytesFromHex] = Field(default=None, description="Header ID hash in hex format.") + version: Version = Field(default=Version.Bedrock, description="Block version.") parent_block: BytesFromHex = Field(description="Hash in hex format.") slot: int = Field(description="Integer in u64 format.") block_root: BytesFromHex = Field(description="Hash in hex format.") proof_of_leadership: ProofOfLeadershipSerializerField + @computed_field + @property + def hash(self) -> bytes: + """Return the header hash (id if available, otherwise block_root).""" + return self.id if self.id is not None else self.block_root + @classmethod def from_random(cls, *, slot: Option[int]) -> Self: return cls.model_validate( { "id": random_hash().hex(), + "version": Version.Bedrock, "parent_block": random_hash().hex(), "slot": slot.unwrap_or_else(lambda: randint(0, 10_000)), "block_root": random_hash().hex(), "proof_of_leadership": ProofOfLeadershipSerializer.from_random(slot=slot), } - ) + ) \ No newline at end of file diff --git a/src/node/api/serializers/operation.py b/src/node/api/serializers/operation.py index 8bad217..dfab073 100644 --- a/src/node/api/serializers/operation.py +++ b/src/node/api/serializers/operation.py @@ -1,9 +1,9 @@ from abc import ABC, abstractmethod -from enum import Enum +from enum import IntEnum from random import choice, randint -from typing import Annotated, List, Optional, Self, Union +from typing import Annotated, Any, Dict, List, Optional, Self, Union -from pydantic import Field +from pydantic import Field, model_validator from core.models import NbeSerializer from models.transactions.operations.contents import ( @@ -16,7 +16,7 @@ from models.transactions.operations.contents import ( SDPDeclare, SDPWithdraw, ) -from node.api.serializers.fields import BytesFromHex, BytesFromInt, BytesFromIntArray +from node.api.serializers.fields import BytesFlexible, BytesFromHex, BytesFromInt, BytesFromIntArray from utils.protocols import EnforceSubclassFromRandom from utils.random import random_bytes @@ -28,9 +28,9 @@ class OperationContentSerializer(NbeSerializer, EnforceSubclassFromRandom, ABC): class ChannelInscribeSerializer(OperationContentSerializer): - channel_id: BytesFromIntArray = Field(description="Bytes as a 32-integer array.") - inscription: BytesFromIntArray = Field(description="Bytes as an integer array.") - parent: BytesFromIntArray = Field(description="Bytes as a 32-integer array.") + channel_id: BytesFlexible = Field(description="Bytes as hex or 32-integer array.") + inscription: BytesFlexible = Field(description="Bytes as hex or integer array.") + parent: BytesFlexible = Field(description="Bytes as hex or 32-integer array.") signer: BytesFromHex = Field(description="Public Key in hex format.") def into_operation_content(self) -> ChannelInscribe: @@ -56,11 +56,11 @@ class ChannelInscribeSerializer(OperationContentSerializer): class ChannelBlobSerializer(OperationContentSerializer): - channel: BytesFromIntArray = Field(description="Bytes as a 32-integer array.") - blob: BytesFromIntArray = Field(description="Bytes as a 32-integer array.") + channel: BytesFlexible = Field(description="Bytes as hex or 32-integer array.") + blob: BytesFlexible = Field(description="Bytes as hex or 32-integer array.") blob_size: int da_storage_gas_price: int - parent: BytesFromIntArray = Field(description="Bytes as a 32-integer array.") + parent: BytesFlexible = Field(description="Bytes as hex or 32-integer array.") signer: BytesFromHex = Field(description="Public Key in hex format.") def into_operation_content(self) -> ChannelBlob: @@ -90,7 +90,7 @@ class ChannelBlobSerializer(OperationContentSerializer): class ChannelSetKeysSerializer(OperationContentSerializer): - channel: BytesFromIntArray = Field(description="Bytes as a 32-integer array.") + channel: BytesFlexible = Field(description="Bytes as hex or 32-integer array.") keys: List[BytesFromHex] = Field(description="List of Public Keys in hex format.") def into_operation_content(self) -> ChannelSetKeys: @@ -112,9 +112,9 @@ class ChannelSetKeysSerializer(OperationContentSerializer): ) -class SDPDeclareServiceType(Enum): - BN = "BN" - DA = "DA" +class SDPDeclareServiceType(IntEnum): + BN = 0 + DA = 1 class SDPDeclareSerializer(OperationContentSerializer): @@ -127,7 +127,7 @@ class SDPDeclareSerializer(OperationContentSerializer): def into_operation_content(self) -> SDPDeclare: return SDPDeclare.model_validate( { - "service_type": self.service_type.value, + "service_type": self.service_type.name, "locators": self.locators, "provider_id": self.provider_id, "zk_id": self.zk_id, @@ -140,7 +140,7 @@ class SDPDeclareSerializer(OperationContentSerializer): n = 1 if randint(0, 1) <= 0.5 else randint(1, 5) return cls.model_validate( { - "service_type": choice(list(SDPDeclareServiceType)).value, + "service_type": choice(list(SDPDeclareServiceType)), "locators": [random_bytes(32).hex() for _ in range(n)], "provider_id": list(random_bytes(32)), "zk_id": random_bytes(32).hex(), @@ -174,7 +174,7 @@ class SDPWithdrawSerializer(OperationContentSerializer): class SDPActiveSerializer(OperationContentSerializer): declaration_id: BytesFromIntArray = Field(description="Bytes as a 32-integer array.") nonce: BytesFromInt - metadata: Optional[BytesFromIntArray] = Field(description="Bytes as an integer array.") + metadata: Optional[BytesFromIntArray] = Field(default=None, description="Bytes as an integer array.") def into_operation_content(self) -> SDPActive: return SDPActive.model_validate( @@ -221,6 +221,59 @@ class LeaderClaimSerializer(OperationContentSerializer): ) +class OpCode(IntEnum): + ChannelInscribe = 0 + ChannelBlob = 1 + ChannelSetKeys = 2 + SDPDeclare = 3 + SDPWithdraw = 4 + SDPActive = 5 + LeaderClaim = 6 + + +# Map opcode to serializer class +OPCODE_TO_SERIALIZER: Dict[int, type[OperationContentSerializer]] = { + OpCode.ChannelInscribe: ChannelInscribeSerializer, + OpCode.ChannelBlob: ChannelBlobSerializer, + OpCode.ChannelSetKeys: ChannelSetKeysSerializer, + OpCode.SDPDeclare: SDPDeclareSerializer, + OpCode.SDPWithdraw: SDPWithdrawSerializer, + OpCode.SDPActive: SDPActiveSerializer, + OpCode.LeaderClaim: LeaderClaimSerializer, +} + + +class OperationWrapper(NbeSerializer): + """Wrapper for operations with opcode and payload structure.""" + opcode: int + payload: Dict[str, Any] + + _content: Optional[OperationContentSerializer] = None + + @model_validator(mode="after") + def parse_payload(self) -> Self: + serializer_cls = OPCODE_TO_SERIALIZER.get(self.opcode) + if serializer_cls is None: + raise ValueError(f"Unknown opcode: {self.opcode}") + self._content = serializer_cls.model_validate(self.payload) + return self + + def into_operation_content(self) -> NbeContent: + if self._content is None: + raise ValueError("Content not parsed") + return self._content.into_operation_content() + + @classmethod + def from_random(cls) -> Self: + opcode = choice(list(OpCode)) + serializer_cls = OPCODE_TO_SERIALIZER[opcode] + content = serializer_cls.from_random() + return cls.model_validate({ + "opcode": opcode, + "payload": content.model_dump(), + }) + + type OperationContentSerializerVariants = Union[ ChannelInscribeSerializer, ChannelBlobSerializer, @@ -230,4 +283,4 @@ type OperationContentSerializerVariants = Union[ SDPActiveSerializer, LeaderClaimSerializer, ] -OperationContentSerializerField = Annotated[OperationContentSerializerVariants, Field(union_mode="left_to_right")] +OperationContentSerializerField = Annotated[OperationContentSerializerVariants, Field(union_mode="left_to_right")] \ No newline at end of file diff --git a/src/node/api/serializers/proof.py b/src/node/api/serializers/proof.py index ae08228..dbea86e 100644 --- a/src/node/api/serializers/proof.py +++ b/src/node/api/serializers/proof.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -from typing import Annotated, Self, Union +from typing import Annotated, Any, Dict, Self, Union -from pydantic import Field, RootModel +from pydantic import Field, RootModel, model_validator from core.models import NbeSerializer from models.transactions.operations.proofs import ( @@ -10,7 +10,7 @@ from models.transactions.operations.proofs import ( ZkAndEd25519Signature, ZkSignature, ) -from node.api.serializers.fields import BytesFromHex +from node.api.serializers.fields import BytesFromHex, BytesFromIntArray from utils.protocols import EnforceSubclassFromRandom from utils.random import random_bytes @@ -21,57 +21,77 @@ class OperationProofSerializer(EnforceSubclassFromRandom, ABC): raise NotImplementedError -# TODO: Differentiate between Ed25519SignatureSerializer and ZkSignatureSerializer - - -class Ed25519SignatureSerializer(OperationProofSerializer, RootModel[str]): - root: BytesFromHex +class Ed25519SignatureSerializer(OperationProofSerializer, NbeSerializer): + """Ed25519 signature as int array, wrapped in Ed25519Sig key.""" + signature: BytesFromIntArray = Field(alias="Ed25519Sig") def into_operation_proof(self) -> NbeSignature: return Ed25519Signature.model_validate( { - "signature": self.root, + "signature": self.signature, } ) @classmethod def from_random(cls, *args, **kwargs) -> Self: - return cls.model_validate(random_bytes(64).hex()) + return cls.model_validate({"Ed25519Sig": list(random_bytes(64))}) -class ZkSignatureSerializer(OperationProofSerializer, RootModel[str]): - root: BytesFromHex +class ZkSignatureComponentsSerializer(NbeSerializer): + """ZK signature proof with pi_a, pi_b, pi_c components as int arrays.""" + pi_a: BytesFromIntArray = Field(description="32 bytes as int array") + pi_b: BytesFromIntArray = Field(description="64 bytes as int array") + pi_c: BytesFromIntArray = Field(description="32 bytes as int array") + + +class ZkSignatureSerializer(OperationProofSerializer, NbeSerializer): + """ZK signature wrapped in ZkSig key.""" + zk_sig: ZkSignatureComponentsSerializer = Field(alias="ZkSig") def into_operation_proof(self) -> NbeSignature: + # Concatenate the components for storage + signature = self.zk_sig.pi_a + self.zk_sig.pi_b + self.zk_sig.pi_c return ZkSignature.model_validate( { - "signature": self.root, + "signature": signature, } ) @classmethod def from_random(cls, *args, **kwargs) -> Self: - return cls.model_validate(random_bytes(32).hex()) + return cls.model_validate({ + "ZkSig": { + "pi_a": list(random_bytes(32)), + "pi_b": list(random_bytes(64)), + "pi_c": list(random_bytes(32)), + } + }) class ZkAndEd25519SignaturesSerializer(OperationProofSerializer, NbeSerializer): - zk_signature: BytesFromHex = Field(alias="zk_sig") - ed25519_signature: BytesFromHex = Field(alias="ed25519_sig") + """Combined ZK and Ed25519 signatures.""" + zk_signature: ZkSignatureComponentsSerializer = Field(alias="zk_sig") + ed25519_signature: BytesFromIntArray = Field(alias="ed25519_sig") def into_operation_proof(self) -> NbeSignature: + zk_sig = self.zk_signature.pi_a + self.zk_signature.pi_b + self.zk_signature.pi_c return ZkAndEd25519Signature.model_validate( { - "zk_signature": self.zk_signature, + "zk_signature": zk_sig, "ed25519_signature": self.ed25519_signature, } ) @classmethod def from_random(cls, *args, **kwargs) -> Self: - return ZkAndEd25519SignaturesSerializer.model_validate( + return cls.model_validate( { - "zk_sig": random_bytes(32).hex(), - "ed25519_sig": random_bytes(32).hex(), + "zk_sig": { + "pi_a": list(random_bytes(32)), + "pi_b": list(random_bytes(64)), + "pi_c": list(random_bytes(32)), + }, + "ed25519_sig": list(random_bytes(64)), } ) @@ -79,4 +99,4 @@ class ZkAndEd25519SignaturesSerializer(OperationProofSerializer, NbeSerializer): OperationProofSerializerVariants = Union[ Ed25519SignatureSerializer, ZkSignatureSerializer, ZkAndEd25519SignaturesSerializer ] -OperationProofSerializerField = Annotated[OperationProofSerializerVariants, Field(union_mode="left_to_right")] +OperationProofSerializerField = Annotated[OperationProofSerializerVariants, Field(union_mode="left_to_right")] \ No newline at end of file diff --git a/src/node/api/serializers/proof_of_leadership.py b/src/node/api/serializers/proof_of_leadership.py index 5636ca4..7762508 100644 --- a/src/node/api/serializers/proof_of_leadership.py +++ b/src/node/api/serializers/proof_of_leadership.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import Annotated, Optional, Self, Union -from pydantic import Field +from pydantic import BeforeValidator, Field from rusty_results import Option from core.models import NbeSerializer @@ -22,13 +22,11 @@ class ProofOfLeadershipSerializer(NbeSerializer, EnforceSubclassFromRandom, ABC) class Groth16LeaderProofSerializer(ProofOfLeadershipSerializer, NbeSerializer): - entropy_contribution: BytesFromHex = Field(description="Fr integer.") - leader_key: BytesFromHex = Field(description="Bytes in Integer Array format.") - proof: BytesFromIntArray = Field( - description="Bytes in Integer Array format.", - ) - public: Optional[PublicSerializer] = Field(description="Only received if Node is running in dev mode.") - voucher_cm: BytesFromHex = Field(description="Hash.") + proof: BytesFromIntArray = Field(description="Bytes in Integer Array format (128 bytes).") + entropy_contribution: BytesFromHex = Field(description="Fr integer in hex.") + leader_key: BytesFromHex = Field(description="Ed25519PublicKey in hex.") + voucher_cm: BytesFromHex = Field(description="VoucherCm hash in hex.") + public: Optional[PublicSerializer] = Field(default=None, description="Only received if Node is running in dev mode.") def into_proof_of_leadership(self) -> ProofOfLeadership: public = self.public.into_public() if self.public else None @@ -46,29 +44,23 @@ class Groth16LeaderProofSerializer(ProofOfLeadershipSerializer, NbeSerializer): def from_random(cls, *, slot: Option[int]) -> Self: return cls.model_validate( { + "proof": list(random_bytes(128)), "entropy_contribution": random_bytes(32).hex(), "leader_key": random_bytes(32).hex(), - "proof": list(random_bytes(128)), - "public": PublicSerializer.from_random(slot), "voucher_cm": random_bytes(32).hex(), + "public": PublicSerializer.from_random(slot), } ) # Fake Variant that never resolves to allow union type checking to work -# TODO: Remove this when another Variant is added -from pydantic import BeforeValidator - - def _always_fail(_): raise ValueError("Never matches.") _NeverType = Annotated[object, BeforeValidator(_always_fail)] -# - ProofOfLeadershipVariants = Union[ Groth16LeaderProofSerializer, _NeverType ] # TODO: Remove _NeverType when another Variant is added -ProofOfLeadershipSerializerField = Annotated[ProofOfLeadershipVariants, Field(union_mode="left_to_right")] +ProofOfLeadershipSerializerField = Annotated[ProofOfLeadershipVariants, Field(union_mode="left_to_right")] \ No newline at end of file diff --git a/src/node/api/serializers/signed_transaction.py b/src/node/api/serializers/signed_transaction.py index 94761a2..59d08b4 100644 --- a/src/node/api/serializers/signed_transaction.py +++ b/src/node/api/serializers/signed_transaction.py @@ -1,14 +1,14 @@ from typing import List, Self from pydantic import Field -from rusty_results import Option from core.models import NbeSerializer from models.transactions.transaction import Transaction -from node.api.serializers.fields import BytesFromHex +from node.api.serializers.fields import BytesFromIntArray from node.api.serializers.proof import ( OperationProofSerializer, OperationProofSerializerField, + ZkSignatureComponentsSerializer, ) from node.api.serializers.transaction import TransactionSerializer from utils.protocols import FromRandom @@ -20,8 +20,8 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom): operations_proofs: List[OperationProofSerializerField] = Field( alias="ops_proofs", description="List of OperationProof. Order should match `Self::transaction::operations`." ) - ledger_transaction_proof: BytesFromHex = Field( - alias="ledger_tx_proof", description="Hash.", min_length=128, max_length=128 + ledger_transaction_proof: ZkSignatureComponentsSerializer = Field( + alias="ledger_tx_proof", description="ZK proof with pi_a, pi_b, pi_c." ) def into_transaction(self) -> Transaction: @@ -42,13 +42,20 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom): ledger_transaction = self.transaction.ledger_transaction outputs = [output.into_note() for output in ledger_transaction.outputs] + # Combine pi_a, pi_b, pi_c into single proof bytes + proof_bytes = ( + self.ledger_transaction_proof.pi_a + + self.ledger_transaction_proof.pi_b + + self.ledger_transaction_proof.pi_c + ) + return Transaction.model_validate( { "hash": self.transaction.hash, "operations": operations, "inputs": ledger_transaction.inputs, "outputs": outputs, - "proof": self.ledger_transaction_proof, + "proof": proof_bytes, "execution_gas_price": self.transaction.execution_gas_price, "storage_gas_price": self.transaction.storage_gas_price, } @@ -60,5 +67,13 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom): n = len(transaction.operations_contents) operations_proofs = [OperationProofSerializer.from_random() for _ in range(n)] return cls.model_validate( - {"mantle_tx": transaction, "ops_proofs": operations_proofs, "ledger_tx_proof": random_bytes(128).hex()} - ) + { + "mantle_tx": transaction, + "ops_proofs": operations_proofs, + "ledger_tx_proof": { + "pi_a": list(random_bytes(32)), + "pi_b": list(random_bytes(64)), + "pi_c": list(random_bytes(32)), + }, + } + ) \ No newline at end of file diff --git a/src/node/api/serializers/transaction.py b/src/node/api/serializers/transaction.py index 44e99ed..9b554be 100644 --- a/src/node/api/serializers/transaction.py +++ b/src/node/api/serializers/transaction.py @@ -6,17 +6,14 @@ from pydantic import Field from core.models import NbeSerializer from node.api.serializers.fields import BytesFromHex from node.api.serializers.ledger_transaction import LedgerTransactionSerializer -from node.api.serializers.operation import ( - OperationContentSerializer, - OperationContentSerializerField, -) +from node.api.serializers.operation import OperationWrapper from utils.protocols import FromRandom from utils.random import random_bytes class TransactionSerializer(NbeSerializer, FromRandom): - hash: BytesFromHex = Field(description="Hash id in hex format.") - operations_contents: List[OperationContentSerializerField] = Field(alias="ops") + hash: BytesFromHex = Field(description="Transaction hash in hex format.") + operations_contents: List[OperationWrapper] = Field(alias="ops") ledger_transaction: LedgerTransactionSerializer = Field(alias="ledger_tx") execution_gas_price: int = Field(description="Integer in u64 format.") storage_gas_price: int = Field(description="Integer in u64 format.") @@ -24,7 +21,7 @@ class TransactionSerializer(NbeSerializer, FromRandom): @classmethod def from_random(cls) -> Self: n = 0 if randint(0, 1) <= 0.5 else randint(1, 5) - operations_contents = [OperationContentSerializer.from_random() for _ in range(n)] + operations_contents = [OperationWrapper.from_random() for _ in range(n)] return cls.model_validate( { "hash": random_bytes(32).hex(), @@ -33,4 +30,4 @@ class TransactionSerializer(NbeSerializer, FromRandom): "execution_gas_price": randint(1, 10_000), "storage_gas_price": randint(1, 10_000), } - ) + ) \ No newline at end of file