diff --git a/src/api/v1/serializers/transactions.py b/src/api/v1/serializers/transactions.py index c6227e5..3d94e61 100644 --- a/src/api/v1/serializers/transactions.py +++ b/src/api/v1/serializers/transactions.py @@ -3,7 +3,6 @@ from typing import List, Self from core.models import NbeSchema from core.types import HexBytes from models.aliases import Gas -from models.transactions.notes import Note from models.transactions.operations.operation import Operation from models.transactions.transaction import Transaction @@ -13,9 +12,6 @@ class TransactionRead(NbeSchema): block_hash: HexBytes hash: HexBytes operations: List[Operation] - inputs: List[HexBytes] - outputs: List[Note] - proof: HexBytes execution_gas_price: Gas storage_gas_price: Gas @@ -26,9 +22,6 @@ class TransactionRead(NbeSchema): block_hash=transaction.block.hash, hash=transaction.hash, operations=transaction.operations, - inputs=transaction.inputs, - outputs=transaction.outputs, - proof=transaction.proof, execution_gas_price=transaction.execution_gas_price, storage_gas_price=transaction.storage_gas_price, ) diff --git a/src/models/transactions/operations/contents.py b/src/models/transactions/operations/contents.py index ef0c454..6888deb 100644 --- a/src/models/transactions/operations/contents.py +++ b/src/models/transactions/operations/contents.py @@ -3,9 +3,11 @@ from typing import List, Literal, Optional from core.models import NbeSchema from core.types import HexBytes +from models.transactions.notes import Note class ContentType(Enum): + LEDGER_TRANSFER = "LedgerTransfer" CHANNEL_INSCRIBE = "ChannelInscribe" CHANNEL_BLOB = "ChannelBlob" CHANNEL_SET_KEYS = "ChannelSetKeys" @@ -19,6 +21,12 @@ class NbeContent(NbeSchema): type: str +class LedgerTransfer(NbeContent): + type: Literal["LedgerTransfer"] = "LedgerTransfer" + inputs: List[HexBytes] + outputs: List[Note] + + class ChannelInscribe(NbeContent): type: Literal["ChannelInscribe"] = "ChannelInscribe" channel_id: HexBytes @@ -77,4 +85,6 @@ class LeaderClaim(NbeContent): mantle_tx_hash: HexBytes -OperationContent = ChannelInscribe | ChannelBlob | ChannelSetKeys | SDPDeclare | SDPWithdraw | SDPActive | LeaderClaim +OperationContent = ( + LedgerTransfer | ChannelInscribe | ChannelBlob | ChannelSetKeys | SDPDeclare | SDPWithdraw | SDPActive | LeaderClaim +) diff --git a/src/models/transactions/transaction.py b/src/models/transactions/transaction.py index 571d3f6..bed0365 100644 --- a/src/models/transactions/transaction.py +++ b/src/models/transactions/transaction.py @@ -1,15 +1,14 @@ import logging from typing import List, Optional -from sqlalchemy import JSON, Column +from sqlalchemy import Column from sqlmodel import Field, Relationship from core.models import TimestampedModel from core.sqlmodel import PydanticJsonColumn from core.types import HexBytes -from models.aliases import Fr, Gas +from models.aliases import Gas from models.block import Block -from models.transactions.notes import Note from models.transactions.operations.operation import Operation logger = logging.getLogger(__name__) @@ -25,11 +24,6 @@ class Transaction(TimestampedModel, table=True): operations: List[Operation] = Field( default_factory=list, sa_column=Column(PydanticJsonColumn(Operation, many=True), nullable=False) ) - inputs: List[Fr] = Field(default_factory=list, sa_column=Column(PydanticJsonColumn(Fr, many=True), nullable=False)) - outputs: List[Note] = Field( - default_factory=list, sa_column=Column(PydanticJsonColumn(Note, many=True), nullable=False) - ) - proof: HexBytes = Field(min_length=128, max_length=128, nullable=False) execution_gas_price: Gas storage_gas_price: Gas diff --git a/src/node/api/serializers/ledger_transaction.py b/src/node/api/serializers/ledger_transaction.py deleted file mode 100644 index acbc1d6..0000000 --- a/src/node/api/serializers/ledger_transaction.py +++ /dev/null @@ -1,27 +0,0 @@ -from random import randint -from typing import List, Self - -from pydantic import Field - -from core.models import NbeSerializer -from node.api.serializers.fields import BytesFromHex -from node.api.serializers.note import NoteSerializer -from utils.protocols import FromRandom -from utils.random import random_bytes - - -class LedgerTransactionSerializer(NbeSerializer, FromRandom): - inputs: List[BytesFromHex] = Field(description="Fr integer.") - outputs: List[NoteSerializer] - - @classmethod - def from_random(cls) -> Self: - n_inputs = 0 if randint(0, 1) <= 0.5 else randint(1, 5) - n_outputs = 0 if randint(0, 1) <= 0.5 else randint(1, 5) - - return cls.model_validate( - { - "inputs": [random_bytes(32).hex() for _ in range(n_inputs)], - "outputs": [NoteSerializer.from_random() for _ in range(n_outputs)], - } - ) diff --git a/src/node/api/serializers/operation.py b/src/node/api/serializers/operation.py index 4700e30..e03ee81 100644 --- a/src/node/api/serializers/operation.py +++ b/src/node/api/serializers/operation.py @@ -1,48 +1,45 @@ -from abc import ABC, abstractmethod -from enum import Enum -from random import choice, randint -from typing import Annotated, Any, List, Optional, Self, Union +from random import randint +from typing import Annotated, Any, List, Self, Union from pydantic import BeforeValidator, Field from core.models import NbeSerializer -from models.transactions.operations.contents import ( - ChannelBlob, - ChannelInscribe, - ChannelSetKeys, - LeaderClaim, - NbeContent, - SDPActive, - SDPDeclare, - SDPWithdraw, -) -from node.api.serializers.fields import BytesFromHex, BytesFromInt, BytesFromIntArray -from utils.protocols import EnforceSubclassFromRandom +from node.api.serializers.fields import BytesFromHex, BytesFromIntArray +from node.api.serializers.note import NoteSerializer +from utils.protocols import FromRandom from utils.random import random_bytes - -class OperationContentSerializer(NbeSerializer, EnforceSubclassFromRandom, ABC): - @abstractmethod - def into_operation_content(self) -> NbeContent: - raise NotImplementedError +# Mantle op opcodes (new node release). +OPCODE_LEDGER = 0 +OPCODE_CHANNEL_INSCRIBE = 17 -class ChannelInscribeSerializer(OperationContentSerializer): - channel_id: BytesFromHex = Field(description="Channel ID in hex format.") - inscription: BytesFromIntArray = Field(description="Bytes as an integer array.") - parent: BytesFromHex = Field(description="Parent hash in hex format.") - signer: BytesFromHex = Field(description="Public Key in hex format.") +class LedgerOpSerializer(NbeSerializer, FromRandom): + """Mantle ledger op (opcode 0): consumes input notes and produces outputs.""" - def into_operation_content(self) -> ChannelInscribe: - return ChannelInscribe.model_validate( + inputs: List[BytesFromHex] = Field(description="Input note IDs (Fr).") + outputs: List[NoteSerializer] + + @classmethod + def from_random(cls) -> Self: + n_inputs = 0 if randint(0, 1) <= 0.5 else randint(1, 5) + n_outputs = 0 if randint(0, 1) <= 0.5 else randint(1, 5) + return cls.model_validate( { - "channel_id": self.channel_id, - "inscription": self.inscription, - "parent": self.parent, - "signer": self.signer, + "inputs": [random_bytes(32).hex() for _ in range(n_inputs)], + "outputs": [NoteSerializer.from_random() for _ in range(n_outputs)], } ) + +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).") + parent: BytesFromHex = Field(description="Parent inscription hash in hex format.") + signer: BytesFromHex = Field(description="Signer public key in hex format.") + @classmethod def from_random(cls) -> Self: return cls.model_validate( @@ -55,206 +52,25 @@ class ChannelInscribeSerializer(OperationContentSerializer): ) -class ChannelBlobSerializer(OperationContentSerializer): - channel: BytesFromHex = Field(description="Channel ID in hex format.") - blob: BytesFromIntArray = Field(description="Bytes as an integer array.") - blob_size: int - da_storage_gas_price: int - parent: BytesFromHex = Field(description="Parent hash in hex format.") - signer: BytesFromHex = Field(description="Public Key in hex format.") - - def into_operation_content(self) -> ChannelBlob: - return ChannelBlob.model_validate( - { - "channel": self.channel, - "blob": self.blob, - "blob_size": self.blob_size, - "da_storage_gas_price": self.da_storage_gas_price, - "parent": self.parent, - "signer": self.signer, - } - ) - - @classmethod - def from_random(cls) -> Self: - return cls.model_validate( - { - "channel": random_bytes(32).hex(), - "blob": list(random_bytes(32)), - "blob_size": randint(1, 1_024), - "da_storage_gas_price": randint(1, 10_000), - "parent": random_bytes(32).hex(), - "signer": random_bytes(32).hex(), - } - ) - - -class ChannelSetKeysSerializer(OperationContentSerializer): - channel: BytesFromHex = Field(description="Channel ID in hex format.") - keys: List[BytesFromHex] = Field(description="List of Public Keys in hex format.") - - def into_operation_content(self) -> ChannelSetKeys: - return ChannelSetKeys.model_validate( - { - "channel": self.channel, - "keys": self.keys, - } - ) - - @classmethod - def from_random(cls) -> Self: - n = 1 if randint(0, 1) <= 0.5 else randint(1, 5) - return cls.model_validate( - { - "channel": random_bytes(32).hex(), - "keys": [random_bytes(32).hex() for _ in range(n)], - } - ) - - -class SDPDeclareServiceType(Enum): - BN = "BN" - DA = "DA" - - -class SDPDeclareSerializer(OperationContentSerializer): - service_type: SDPDeclareServiceType - locators: List[BytesFromHex] - provider_id: BytesFromHex = Field(description="Provider ID in hex format.") - zk_id: BytesFromHex = Field(description="Fr integer.") - locked_note_id: BytesFromHex = Field(description="Fr integer.") - - def into_operation_content(self) -> SDPDeclare: - return SDPDeclare.model_validate( - { - "service_type": self.service_type.value, - "locators": self.locators, - "provider_id": self.provider_id, - "zk_id": self.zk_id, - "locked_note_id": self.locked_note_id, - } - ) - - @classmethod - def from_random(cls) -> Self: - n = 1 if randint(0, 1) <= 0.5 else randint(1, 5) - return cls.model_validate( - { - "service_type": choice(list(SDPDeclareServiceType)).value, - "locators": [random_bytes(32).hex() for _ in range(n)], - "provider_id": random_bytes(32).hex(), - "zk_id": random_bytes(32).hex(), - "locked_note_id": random_bytes(32).hex(), - } - ) - - -class SDPWithdrawSerializer(OperationContentSerializer): - declaration_id: BytesFromHex = Field(description="Declaration ID in hex format.") - nonce: BytesFromInt - - def into_operation_content(self) -> SDPWithdraw: - return SDPWithdraw.model_validate( - { - "declaration_id": self.declaration_id, - "nonce": self.nonce, - } - ) - - @classmethod - def from_random(cls) -> Self: - return cls.model_validate( - { - "declaration_id": random_bytes(32).hex(), - "nonce": int.from_bytes(random_bytes(8)), - } - ) - - -class SDPActiveSerializer(OperationContentSerializer): - declaration_id: BytesFromHex = Field(description="Declaration ID in hex format.") - nonce: BytesFromInt - metadata: Optional[BytesFromIntArray] = Field(description="Bytes as an integer array.") - - def into_operation_content(self) -> SDPActive: - return SDPActive.model_validate( - { - "declaration_id": self.declaration_id, - "nonce": self.nonce, - "metadata": self.metadata, - } - ) - - @classmethod - def from_random(cls) -> Self: - return cls.model_validate( - { - "declaration_id": random_bytes(32).hex(), - "nonce": int.from_bytes(random_bytes(8)), - "metadata": None if randint(0, 1) <= 0.5 else list(random_bytes(32)), - } - ) - - -class LeaderClaimSerializer(OperationContentSerializer): - rewards_root: BytesFromInt = Field(description="Fr integer.") - voucher_nullifier: BytesFromInt = Field(description="Fr integer.") - mantle_tx_hash: BytesFromInt = Field(description="Fr integer.") - - def into_operation_content(self) -> LeaderClaim: - return LeaderClaim.model_validate( - { - "rewards_root": self.rewards_root, - "voucher_nullifier": self.voucher_nullifier, - "mantle_tx_hash": self.mantle_tx_hash, - } - ) - - @classmethod - def from_random(cls) -> Self: - return cls.model_validate( - { - "rewards_root": int.from_bytes(random_bytes(8)), - "voucher_nullifier": int.from_bytes(random_bytes(8)), - "mantle_tx_hash": int.from_bytes(random_bytes(8)), - } - ) - - -OPCODE_TO_SERIALIZER: dict[int, type[OperationContentSerializer]] = { - 0: ChannelInscribeSerializer, - 1: ChannelBlobSerializer, - 2: ChannelSetKeysSerializer, - 3: SDPDeclareSerializer, - 4: SDPWithdrawSerializer, - 5: SDPActiveSerializer, - 6: LeaderClaimSerializer, +OPCODE_TO_SERIALIZER: dict[int, type] = { + OPCODE_LEDGER: LedgerOpSerializer, + OPCODE_CHANNEL_INSCRIBE: ChannelInscribeOpSerializer, } -def _parse_operation(data: Any) -> OperationContentSerializer: - if isinstance(data, OperationContentSerializer): +def _parse_mantle_op(data: Any) -> Union[LedgerOpSerializer, ChannelInscribeOpSerializer]: + if isinstance(data, (LedgerOpSerializer, ChannelInscribeOpSerializer)): return data if isinstance(data, dict) and "opcode" in data: opcode = data["opcode"] - payload = data["payload"] serializer_class = OPCODE_TO_SERIALIZER.get(opcode) if serializer_class is None: - raise ValueError(f"Unknown operation opcode: {opcode}") - return serializer_class.model_validate(payload) - return data + raise ValueError( + f"Unsupported mantle op opcode {opcode}; known opcodes: {sorted(OPCODE_TO_SERIALIZER)}." + ) + return serializer_class.model_validate(data["payload"]) + raise ValueError(f"Cannot parse mantle op from {type(data).__name__}.") -type OperationContentSerializerVariants = Union[ - ChannelInscribeSerializer, - ChannelBlobSerializer, - ChannelSetKeysSerializer, - SDPDeclareSerializer, - SDPWithdrawSerializer, - SDPActiveSerializer, - LeaderClaimSerializer, -] -OperationContentSerializerField = Annotated[ - OperationContentSerializerVariants, - BeforeValidator(_parse_operation), -] +MantleOpSerializerVariants = Union[LedgerOpSerializer, ChannelInscribeOpSerializer] +MantleOpSerializerField = Annotated[MantleOpSerializerVariants, BeforeValidator(_parse_mantle_op)] diff --git a/src/node/api/serializers/proof.py b/src/node/api/serializers/proof.py index c2e2df0..7566b0c 100644 --- a/src/node/api/serializers/proof.py +++ b/src/node/api/serializers/proof.py @@ -36,19 +36,32 @@ class Ed25519SignatureSerializer(OperationProofSerializer, RootModel[bytes]): return cls.model_validate(list(random_bytes(64))) -class ZkSignatureSerializer(OperationProofSerializer, RootModel[bytes]): - root: BytesFromIntArray +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 + + def to_bytes(self) -> bytes: + return self.pi_a + self.pi_b + self.pi_c def into_operation_proof(self) -> NbeSignature: return ZkSignature.model_validate( { - "signature": self.root, + "signature": self.to_bytes(), } ) @classmethod def from_random(cls, *args, **kwargs) -> Self: - return cls.model_validate(list(random_bytes(32))) + return cls.model_validate( + { + "pi_a": list(random_bytes(32)), + "pi_b": list(random_bytes(64)), + "pi_c": list(random_bytes(32)), + } + ) class ZkAndEd25519SignaturesSerializer(OperationProofSerializer, NbeSerializer): diff --git a/src/node/api/serializers/signed_transaction.py b/src/node/api/serializers/signed_transaction.py index 8ffe9fd..debb8c1 100644 --- a/src/node/api/serializers/signed_transaction.py +++ b/src/node/api/serializers/signed_transaction.py @@ -6,41 +6,25 @@ from pydantic import Field from core.models import NbeSerializer from models.transactions.transaction import Transaction -from node.api.serializers.fields import BytesFromIntArray +from node.api.serializers.operation import ( + ChannelInscribeOpSerializer, + LedgerOpSerializer, +) from node.api.serializers.proof import ( - OperationProofSerializer, + Ed25519SignatureSerializer, OperationProofSerializerField, + ZkSignatureSerializer, ) from node.api.serializers.transaction import TransactionSerializer from utils.protocols import FromRandom -from utils.random import random_bytes - - -class Groth16ProofSerializer(NbeSerializer, FromRandom): - pi_a: BytesFromIntArray - pi_b: BytesFromIntArray - pi_c: BytesFromIntArray - - def to_bytes(self) -> bytes: - return self.pi_a + self.pi_b + self.pi_c - - @classmethod - def from_random(cls) -> Self: - return cls.model_validate( - { - "pi_a": list(random_bytes(32)), - "pi_b": list(random_bytes(64)), - "pi_c": list(random_bytes(32)), - } - ) class SignedTransactionSerializer(NbeSerializer, FromRandom): transaction: TransactionSerializer = Field(alias="mantle_tx", description="Transaction.") operations_proofs: List[OperationProofSerializerField] = Field( - alias="ops_proofs", description="List of OperationProof. Order should match `Self::transaction::operations`." + alias="ops_proofs", + description="List of OperationProof. Order should match `Self::transaction::ops`.", ) - ledger_transaction_proof: Groth16ProofSerializer = Field(alias="ledger_tx_proof", description="Groth16 proof.") def _compute_hash(self) -> bytes: data = self.transaction.model_dump(mode="json") @@ -48,30 +32,60 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom): return hashlib.sha256(canonical.encode()).digest() def into_transaction(self) -> Transaction: - operations_contents = self.transaction.operations_contents - if len(operations_contents) != len(self.operations_proofs): + ops = self.transaction.ops + if len(ops) != len(self.operations_proofs): raise ValueError( - f"Number of operations ({len(operations_contents)}) does not match number of operation proofs ({len(self.operations_proofs)})." + f"Number of ops ({len(ops)}) does not match number of op proofs " + f"({len(self.operations_proofs)})." ) - operations = [ - { - "content": content.into_operation_content(), - "proof": proof.into_operation_proof(), - } - for content, proof in zip(operations_contents, self.operations_proofs) - ] - - ledger_transaction = self.transaction.ledger_transaction - outputs = [output.into_note() for output in ledger_transaction.outputs] + operations: List[dict] = [] + for op, proof in zip(ops, self.operations_proofs): + if isinstance(op, LedgerOpSerializer): + if not isinstance(proof, ZkSignatureSerializer): + raise ValueError( + f"Expected a ZkSig (Groth16) proof for the ledger op, got {type(proof).__name__}." + ) + operations.append( + { + "content": { + "type": "LedgerTransfer", + "inputs": list(op.inputs), + "outputs": [o.into_note() for o in op.outputs], + }, + "proof": { + "type": "Zk", + "signature": proof.to_bytes(), + }, + } + ) + elif isinstance(op, ChannelInscribeOpSerializer): + if not isinstance(proof, Ed25519SignatureSerializer): + raise ValueError( + f"Expected an Ed25519Sig proof for the channel inscribe op, got {type(proof).__name__}." + ) + operations.append( + { + "content": { + "type": "ChannelInscribe", + "channel_id": op.channel_id, + "inscription": op.inscription, + "parent": op.parent, + "signer": op.signer, + }, + "proof": { + "type": "Ed25519", + "signature": proof.root, + }, + } + ) + else: + raise ValueError(f"Unsupported mantle op type: {type(op).__name__}") return Transaction.model_validate( { "hash": self._compute_hash(), "operations": operations, - "inputs": ledger_transaction.inputs, - "outputs": outputs, - "proof": self.ledger_transaction_proof.to_bytes(), "execution_gas_price": self.transaction.execution_gas_price, "storage_gas_price": self.transaction.storage_gas_price, } @@ -80,12 +94,10 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom): @classmethod def from_random(cls) -> Self: transaction = TransactionSerializer.from_random() - n = len(transaction.operations_contents) - operations_proofs = [OperationProofSerializer.from_random() for _ in range(n)] + operations_proofs = [ZkSignatureSerializer.from_random() for _ in range(len(transaction.ops))] return cls.model_validate( { "mantle_tx": transaction, "ops_proofs": operations_proofs, - "ledger_tx_proof": Groth16ProofSerializer.from_random(), } ) diff --git a/src/node/api/serializers/transaction.py b/src/node/api/serializers/transaction.py index 0523586..3ffab43 100644 --- a/src/node/api/serializers/transaction.py +++ b/src/node/api/serializers/transaction.py @@ -4,29 +4,22 @@ from typing import List, Self from pydantic import Field from core.models import NbeSerializer -from node.api.serializers.ledger_transaction import LedgerTransactionSerializer -from node.api.serializers.operation import ( - OperationContentSerializer, - OperationContentSerializerField, -) +from node.api.serializers.operation import LedgerOpSerializer, MantleOpSerializerField from utils.protocols import FromRandom -from utils.random import random_bytes class TransactionSerializer(NbeSerializer, FromRandom): - operations_contents: List[OperationContentSerializerField] = Field(alias="ops") - ledger_transaction: LedgerTransactionSerializer = Field(alias="ledger_tx") + ops: List[MantleOpSerializerField] execution_gas_price: int = Field(description="Integer in u64 format.") storage_gas_price: int = Field(description="Integer in u64 format.") @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)] + n = 1 if randint(0, 1) <= 0.5 else randint(1, 3) + ops = [LedgerOpSerializer.from_random() for _ in range(n)] return cls.model_validate( { - "ops": operations_contents, - "ledger_tx": LedgerTransactionSerializer.from_random(), + "ops": ops, "execution_gas_price": randint(1, 10_000), "storage_gas_price": randint(1, 10_000), } diff --git a/static/components/TransactionsTable.js b/static/components/TransactionsTable.js index 8e751a2..7516c50 100644 --- a/static/components/TransactionsTable.js +++ b/static/components/TransactionsTable.js @@ -83,9 +83,21 @@ function formatOperationsPreview(ops) { } // ---------- normalize API → view model ---------- +// Outputs come from the LedgerTransfer op(s) since the top-level inputs/outputs +// columns were removed (the new mantle schema represents transfers as ops). +function collectTransferOutputs(ops) { + const outputs = []; + for (const op of ops) { + const content = op?.content ?? op; + if (content?.type !== 'LedgerTransfer') continue; + if (Array.isArray(content.outputs)) outputs.push(...content.outputs); + } + return outputs; +} + function normalize(raw) { const ops = Array.isArray(raw?.operations) ? raw.operations : Array.isArray(raw?.ops) ? raw.ops : []; - const outputs = Array.isArray(raw?.outputs) ? raw.outputs : []; + const outputs = collectTransferOutputs(ops); const totalOutputValue = outputs.reduce((sum, note) => sum + toNumber(note?.value), 0); return { diff --git a/static/pages/BlockDetail.js b/static/pages/BlockDetail.js index c7d28f9..88b2b78 100644 --- a/static/pages/BlockDetail.js +++ b/static/pages/BlockDetail.js @@ -40,7 +40,14 @@ function opsToPills(ops, limit = OPERATIONS_PREVIEW_LIMIT) { } function computeOutputsSummaryFromTx(tx) { - const outputs = Array.isArray(tx?.outputs) ? tx.outputs : []; + // Outputs now live inside LedgerTransfer ops; aggregate across them. + const ops = Array.isArray(tx?.operations) ? tx.operations : []; + const outputs = []; + for (const op of ops) { + const content = op?.content ?? op; + if (content?.type !== 'LedgerTransfer') continue; + if (Array.isArray(content.outputs)) outputs.push(...content.outputs); + } const count = outputs.length; const total = outputs.reduce((sum, o) => sum + Number(o?.value ?? 0), 0); return { count, total }; diff --git a/static/pages/TransactionDetail.js b/static/pages/TransactionDetail.js index bcc2b0d..68e1eaf 100644 --- a/static/pages/TransactionDetail.js +++ b/static/pages/TransactionDetail.js @@ -94,35 +94,21 @@ function CopyPill({ text, label = 'Copy' }) { ); } -// ————— normalizer for new TransactionRead ————— -// { id, block_id, hash, operations:[Operation], inputs:[HexBytes], outputs:[Note{public_key:HexBytes,value:int}], -// proof, execution_gas_price, storage_gas_price } +// ————— normalizer for TransactionRead ————— +// { id, block_hash, hash, operations:[Operation], execution_gas_price, storage_gas_price } +// Ledger transfers are now an Operation with content.type === 'LedgerTransfer'. function normalizeTransaction(raw) { const ops = Array.isArray(raw?.operations) ? raw.operations : Array.isArray(raw?.ops) ? raw.ops : []; - const inputs = Array.isArray(raw?.inputs) ? raw.inputs : []; - const outputs = Array.isArray(raw?.outputs) ? raw.outputs : []; - - const totalOutputValue = outputs.reduce((sum, note) => sum + toNumber(note?.value), 0); - return { id: raw?.id ?? '', blockHash: raw?.block_hash ?? null, hash: renderBytes(raw?.hash), - proof: renderBytes(raw?.proof), - operations: ops, // keep objects, we’ll label in UI + operations: ops, executionGasPrice: isNumber(raw?.execution_gas_price) ? raw.execution_gas_price : toNumber(raw?.execution_gas_price), storageGasPrice: isNumber(raw?.storage_gas_price) ? raw.storage_gas_price : toNumber(raw?.storage_gas_price), - ledger: { - inputs: inputs.map((v) => renderBytes(v)), - outputs: outputs.map((n) => ({ - public_key: renderBytes(n?.public_key), - value: toNumber(n?.value), - })), - totalOutputValue, - }, }; } @@ -170,20 +156,6 @@ function Summary({ tx }) { h(CopyPill, { text: tx.hash }), ), - // Proof + copy (if present) - tx.proof && - h( - 'div', - null, - h('b', null, 'Proof: '), - h( - 'span', - { class: 'pill mono', title: tx.proof, style: 'max-width:100%; overflow-wrap:anywhere;' }, - String(tx.proof), - ), - h(CopyPill, { text: tx.proof }), - ), - // Gas h( 'div', @@ -360,7 +332,55 @@ function InscriptionValue({ value }) { return h(FieldValue, { value }); } +function LedgerTransferContent({ content }) { + const inputs = Array.isArray(content?.inputs) ? content.inputs.map((v) => renderBytes(v)) : []; + const rawOutputs = Array.isArray(content?.outputs) ? content.outputs : []; + const outputs = rawOutputs.map((n) => ({ + public_key: renderBytes(n?.public_key), + value: toNumber(n?.value), + })); + const totalOutputValue = outputs.reduce((sum, o) => sum + o.value, 0); + + return h( + 'div', + { style: 'display:grid; gap:16px;' }, + + // Inputs + h( + 'div', + null, + h( + 'div', + { style: 'display:flex; align-items:center; gap:8px;' }, + h('b', null, 'Inputs'), + h('span', { class: 'pill' }, String(inputs.length)), + ), + h(InputsTable, { inputs }), + ), + + // Outputs + h( + 'div', + null, + h( + 'div', + { style: 'display:flex; align-items:center; gap:8px;' }, + h('b', null, 'Outputs'), + h('span', { class: 'pill' }, String(outputs.length)), + h( + 'span', + { class: 'amount', style: 'margin-left:auto;' }, + `Total: ${toLocaleNum(totalOutputValue)}`, + ), + ), + h(OutputsTable, { outputs }), + ), + ); +} + function OperationContent({ content }) { + if (content?.type === 'LedgerTransfer') return h(LedgerTransferContent, { content }); + // Get all fields except "type" const entries = Object.entries(content).filter(([k]) => k !== 'type'); if (!entries.length) return h('div', { style: 'color:var(--muted)' }, 'No fields'); @@ -436,52 +456,6 @@ function Operations({ operations }) { ); } -function Ledger({ ledger }) { - const inputs = Array.isArray(ledger?.inputs) ? ledger.inputs : []; - const outputs = Array.isArray(ledger?.outputs) ? ledger.outputs : []; - const totalOutputValue = toNumber(ledger?.totalOutputValue); - - return h( - SectionCard, - { title: 'Ledger' }, - h( - 'div', - { style: 'display:grid; gap:16px;' }, - - // Inputs - h( - 'div', - null, - h( - 'div', - { style: 'display:flex; alignItems:center; gap:8px;' }, - h('b', null, 'Inputs'), - h('span', { class: 'pill' }, String(inputs.length)), - ), - h(InputsTable, { inputs }), - ), - - // Outputs - h( - 'div', - null, - h( - 'div', - { style: 'display:flex; alignItems:center; gap:8px;' }, - h('b', null, 'Outputs'), - h('span', { class: 'pill' }, String(outputs.length)), - h( - 'span', - { class: 'amount', style: 'margin-left:auto;' }, - `Total: ${toLocaleNum(totalOutputValue)}`, - ), - ), - h(OutputsTable, { outputs }), - ), - ), - ); -} - // ————— page ————— export default function TransactionDetail({ parameters }) { const transactionHash = parameters?.[0]; @@ -571,7 +545,6 @@ export default function TransactionDetail({ parameters }) { null, h(Summary, { tx }), h(Operations, { operations: tx.operations }), - h(Ledger, { ledger: tx.ledger }), ), ); }