update tx schema to v0.1.1

This commit is contained in:
David Rusu 2026-04-09 23:27:30 -04:00
parent db76ad97f8
commit 4fa63da82a
11 changed files with 204 additions and 408 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),
}
)

View File

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

View File

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

View File

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

View File

@ -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, well 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 }),
),
);
}