UNPKG

@ledgerhq/coin-tezos

Version:
581 lines (512 loc) 18.7 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { TezosContext } from '../config' import type { APIBlock, APIDelegationType, APIOriginationType, APIRevealType, APIStakingType, APITokenTransfer, APITransactionType, } from '../network/types' import type { AssetInfo, Block, BlockInfo, BlockOperation, BlockTransaction, } from '@ledgerhq/coin-module-framework/api/index' import { STAKING_ACTION_TO_OP_TYPE } from '../constants' import { createTzktApi, fetchBlockDelegations, fetchBlockOriginations, fetchBlockReveals, fetchBlockStaking, fetchBlockTokenTransfers, fetchBlockTransactions, } from '../network' const NATIVE_ASSET: AssetInfo = { type: 'native', name: 'XTZ' } // --------------------------------------------------------------------------- // Block-info mapping // --------------------------------------------------------------------------- function mapBlockInfo(block: APIBlock, parentBlock: APIBlock): BlockInfo { return { height: block.level, hash: block.hash, time: new Date(block.timestamp), parent: { height: parentBlock.level, hash: parentBlock.hash }, } } // --------------------------------------------------------------------------- // Native XTZ helpers // --------------------------------------------------------------------------- /** Computes bakerFee + storageFee + allocationFee for a single operation. */ function computeOpFees(op: { bakerFee?: number storageFee?: number allocationFee?: number }): bigint { return BigInt(op.bakerFee ?? 0) + BigInt(op.storageFee ?? 0) + BigInt(op.allocationFee ?? 0) } /** * Computes the total fees for an operation group (all ops sharing the same hash). * * In Tezos, `bakerFee` is charged only on the top-level op, but `storageFee` and * `allocationFee` can appear on internal ops emitted by contracts as well. */ function computeFees(group: APITransactionType[]): bigint { return group.reduce((sum, op) => sum + computeOpFees(op), 0n) } /** * Determines who paid the fees for an operation group. * * The fee payer is the top-level op's `initiator` (when a contract triggered the * call) or its `sender` (for direct user operations). The "top-level" op is the * one that carries a non-zero `bakerFee`; if none exists we fall back to the first * op in the group. */ function findFeesPayer(group: APITransactionType[]): string | undefined { const topLevelOp = group.find((op) => (op.bakerFee ?? 0) > 0) ?? group[0] return topLevelOp?.initiator?.address ?? topLevelOp?.sender?.address } /** * Returns `true` only when every op in the group succeeded. * * Tezos can partially execute a batch (later ops get "backtracked"), so we treat * the whole group as failed if any op did not reach "applied" status. Fees are * still charged in that case, but no balance changes took effect. */ function isGroupSucceeded(group: APITransactionType[]): boolean { return group.every((op) => !op.status || op.status === 'applied') } /** * Produces one outgoing and one incoming `BlockOperation` for each transaction in the group * that should be represented in the block. * * Transactions are skipped only when both the transferred amount and the fees are zero. * Fee-only transactions (amount === 0 but fees > 0) are kept so fee attribution and * sender/target linkage match `listOperations`. * * Fees are intentionally excluded from amounts — they are reported separately in * `BlockTransaction.fees`. In Tezos, the `amount` field on `APITransactionType` * already represents only the transferred value, so no adjustment is required * (unlike XRPL, where the raw balance diff includes the fee deduction). */ function buildNativeOperations(group: APITransactionType[]): BlockOperation[] { const ops: BlockOperation[] = [] for (const tx of group) { const amount = BigInt(tx.amount ?? 0) if (amount === 0n && computeOpFees(tx) === 0n) continue const fromAddr = tx.sender?.address const toAddr = tx.target?.address if (fromAddr) { ops.push({ type: 'transfer', address: fromAddr, ...(toAddr && { peer: toAddr }), asset: NATIVE_ASSET, amount: -amount, }) } if (toAddr) { ops.push({ type: 'transfer', address: toAddr, ...(fromAddr && { peer: fromAddr }), asset: NATIVE_ASSET, amount, }) } } return ops } // --------------------------------------------------------------------------- // Delegation helpers // --------------------------------------------------------------------------- const computeDelegationFees = computeOpFees function buildDelegationOperations(op: APIDelegationType): BlockOperation[] { if (!op.sender?.address) return [] const isDelegate = !!op.newDelegate?.address const opType = isDelegate ? 'DELEGATE' : 'UNDELEGATE' return [ { type: 'other', ledgerOpType: opType, operationType: opType, stakedAmount: 0, counter: op.counter, gasLimit: op.gasLimit, storageLimit: op.storageLimit, }, ] } function buildBlockTransactionFromDelegation(op: APIDelegationType): BlockTransaction | null { if (!op.hash) return null const feesPayer = op.sender?.address const succeeded = !op.status || op.status === 'applied' return { hash: op.hash, failed: !succeeded, fees: computeDelegationFees(op), ...(feesPayer && { feesPayer }), // Include operations even for failed standalone txs — listOperations path // always converts them. Note: mergeAuxiliaryTx still clears ops when a // failed auxiliary tx is merged into an existing BlockTransaction. operations: buildDelegationOperations(op), } } // --------------------------------------------------------------------------- // Staking helpers (Paris adaptive issuance) // --------------------------------------------------------------------------- const computeStakingFees = computeOpFees function buildStakingOperations(op: APIStakingType): BlockOperation[] { if (!op.sender?.address) return [] const operationType = STAKING_ACTION_TO_OP_TYPE[op.action] return [ { type: 'other', ledgerOpType: operationType, operationType, stakedAmount: Number(op.amount ?? op.requestedAmount ?? 0), counter: op.counter, gasLimit: op.gasLimit, storageLimit: op.storageLimit, }, ] } function buildBlockTransactionFromStaking(op: APIStakingType): BlockTransaction | null { if (!op.hash) return null const feesPayer = op.sender?.address const succeeded = !op.status || op.status === 'applied' return { hash: op.hash, failed: !succeeded, fees: computeStakingFees(op), ...(feesPayer && { feesPayer }), operations: buildStakingOperations(op), } } // --------------------------------------------------------------------------- // Origination helpers // --------------------------------------------------------------------------- const computeOriginationFees = computeOpFees function buildOriginationOperations(op: APIOriginationType): BlockOperation[] { const senderAddr = op.sender?.address if (!senderAddr) return [] return [ { type: 'other', address: senderAddr, ledgerOpType: 'ORIGINATION', counter: op.counter, gasLimit: op.gasLimit, storageLimit: op.storageLimit, }, ] } function buildBlockTransactionFromOrigination(op: APIOriginationType): BlockTransaction | null { if (!op.hash) return null const feesPayer = op.sender?.address const succeeded = !op.status || op.status === 'applied' return { hash: op.hash, failed: !succeeded, fees: computeOriginationFees(op), ...(feesPayer && { feesPayer }), operations: buildOriginationOperations(op), } } // --------------------------------------------------------------------------- // Reveal helpers // --------------------------------------------------------------------------- const computeRevealFees = computeOpFees function buildRevealOperations(op: APIRevealType): BlockOperation[] { const senderAddr = op.sender?.address if (!senderAddr) return [] return [ { type: 'other', address: senderAddr, ledgerOpType: 'REVEAL', counter: op.counter, gasLimit: op.gasLimit, storageLimit: op.storageLimit, }, ] } function buildBlockTransactionFromReveal(op: APIRevealType): BlockTransaction | null { if (!op.hash) return null const feesPayer = op.sender?.address const succeeded = !op.status || op.status === 'applied' return { hash: op.hash, failed: !succeeded, fees: computeRevealFees(op), ...(feesPayer && { feesPayer }), operations: buildRevealOperations(op), } } // --------------------------------------------------------------------------- // FA token helpers // --------------------------------------------------------------------------- /** * Produces outgoing / incoming `BlockOperation` entries for a single FA transfer. * * Minting events (`from` absent) produce only the incoming entry; burning events * (`to` absent) produce only the outgoing entry. */ function buildTokenOperations(transfer: APITokenTransfer): BlockOperation[] { const tokenAmount = BigInt(transfer.amount) if (tokenAmount === 0n) return [] // For FA2, multiple token IDs coexist under one contract address. // Encoding both as "address:tokenId" makes every token uniquely identifiable. // FA1.2 tokens always have tokenId "0", so this format is safe for both standards. const tokenId = transfer.token.tokenId ?? '0' const asset: AssetInfo = { type: transfer.token.standard, assetReference: `${transfer.token.contract.address}:${tokenId}`, name: transfer.token.metadata?.name ?? transfer.token.metadata?.symbol, } const fromAddr = transfer.from?.address const toAddr = transfer.to?.address const ops: BlockOperation[] = [] if (fromAddr) { ops.push({ type: 'transfer', address: fromAddr, ...(toAddr && { peer: toAddr }), asset, amount: -tokenAmount, }) } if (toAddr) { ops.push({ type: 'transfer', address: toAddr, ...(fromAddr && { peer: fromAddr }), asset, amount: tokenAmount, }) } return ops } // --------------------------------------------------------------------------- // Transaction grouping — private helpers // --------------------------------------------------------------------------- /** Groups native XTZ transactions by their operation hash. */ function groupTransactionsByHash( transactions: APITransactionType[] ): Map<string, APITransactionType[]> { const groups = new Map<string, APITransactionType[]>() for (const tx of transactions) { if (!tx.hash) continue const existing = groups.get(tx.hash) if (existing) existing.push(tx) else groups.set(tx.hash, [tx]) } return groups } /** Builds a `BlockTransaction` from a group of ops that share the same hash. */ function buildBlockTransactionFromGroup( hash: string, group: APITransactionType[] ): BlockTransaction { const succeeded = isGroupSucceeded(group) const blockTx: BlockTransaction = { hash, failed: !succeeded, fees: computeFees(group), operations: succeeded ? buildNativeOperations(group) : [], } const feesPayer = findFeesPayer(group) if (feesPayer) blockTx.feesPayer = feesPayer return blockTx } /** * Attaches a single FA token transfer to the appropriate `BlockTransaction`. * * When the transfer's parent native tx is found (via `transactionId → hash`), * the token ops are appended to it (unless it failed). Otherwise a standalone * entry is created or extended in `standaloneByKey`, grouped by `transactionId` * so that sibling transfers from the same on-chain operation share one entry. */ function attachTokenTransfer( transfer: APITokenTransfer, txIdToHash: Map<number, string>, blockTxByHash: Map<string, BlockTransaction>, standaloneByKey: Map<string, BlockTransaction> ): void { const tokenOps = buildTokenOperations(transfer) if (tokenOps.length === 0) return const parentHash = transfer.transactionId !== undefined ? txIdToHash.get(transfer.transactionId) : transfer.originationId !== undefined ? txIdToHash.get(transfer.originationId) : undefined if (parentHash !== undefined && blockTxByHash.has(parentHash)) { const parent = blockTxByHash.get(parentHash)! if (!parent.failed) parent.operations.push(...tokenOps) return } // No matching BlockTransaction in this block. Use the resolved parent hash // when available (cross-block origination) so the standalone entry carries the // real origination hash — matching what listOperations would produce. const parentId = transfer.transactionId ?? transfer.originationId const key = parentHash ?? (parentId === undefined ? `token-${transfer.id}` : `txid-${parentId}`) const existing = standaloneByKey.get(key) if (existing) { existing.operations.push(...tokenOps) } else { standaloneByKey.set(key, { hash: key, failed: false, fees: 0n, operations: tokenOps, }) } } // --------------------------------------------------------------------------- // Transaction grouping — public orchestrator // --------------------------------------------------------------------------- function mergeAuxiliaryTx( blockTxByHash: Map<string, BlockTransaction>, auxTx: BlockTransaction ): void { const existing = blockTxByHash.get(auxTx.hash) if (!existing) { blockTxByHash.set(auxTx.hash, auxTx) return } // When an auxiliary op (reveal, delegation, etc.) failed, we mark the whole // merged transaction as failed. This is a modeling choice: the auxiliary op // shares the same hash and its failure typically means the batch was aborted. if (auxTx.failed) { existing.failed = true existing.operations = [] } else if (!existing.failed && auxTx.operations.length > 0) { existing.operations.push(...auxTx.operations) } existing.fees += auxTx.fees } function groupAndMapTransactions( transactions: APITransactionType[], tokenTransfers: APITokenTransfer[], delegations: APIDelegationType[], stakings: APIStakingType[], originations: APIOriginationType[], reveals: APIRevealType[], crossBlockIdToHash: Map<number, string> = new Map() ): BlockTransaction[] { const groups = groupTransactionsByHash(transactions) const txIdToHash = new Map<number, string>() for (const tx of transactions) { if (tx.id && tx.hash) txIdToHash.set(tx.id, tx.hash) } for (const orig of originations) { if (orig.id && orig.hash) txIdToHash.set(orig.id, orig.hash) } for (const [id, hash] of crossBlockIdToHash) { txIdToHash.set(id, hash) } const blockTxByHash = new Map<string, BlockTransaction>() for (const [hash, group] of groups) { blockTxByHash.set(hash, buildBlockTransactionFromGroup(hash, group)) } for (const delegation of delegations) { const delegationTx = buildBlockTransactionFromDelegation(delegation) if (delegationTx) mergeAuxiliaryTx(blockTxByHash, delegationTx) } for (const staking of stakings) { const stakingTx = buildBlockTransactionFromStaking(staking) if (stakingTx) mergeAuxiliaryTx(blockTxByHash, stakingTx) } for (const origination of originations) { const originationTx = buildBlockTransactionFromOrigination(origination) if (originationTx) mergeAuxiliaryTx(blockTxByHash, originationTx) } for (const reveal of reveals) { const revealTx = buildBlockTransactionFromReveal(reveal) if (revealTx) mergeAuxiliaryTx(blockTxByHash, revealTx) } const standaloneByKey = new Map<string, BlockTransaction>() for (const transfer of tokenTransfers) { attachTokenTransfer(transfer, txIdToHash, blockTxByHash, standaloneByKey) } return [...blockTxByHash.values(), ...standaloneByKey.values()] } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Returns the full block at the given Tezos level: metadata + all transactions * with their XTZ and FA token balance changes. * * - Fetches block metadata, native transactions, delegations, FA token transfers, and staking operations in parallel. * - Also fetches the predecessor block in parallel to populate `BlockInfo.parent`. * - Groups operations by hash, aggregates fees, and determines the fee payer. * - Appends FA token transfer operations to the owning BlockTransaction when a * matching `transactionId` can be resolved; otherwise creates a standalone entry. */ export async function getBlock(context: TezosContext, height: number): Promise<Block> { if (!Number.isSafeInteger(height) || height <= 0) { throw new Error(`getBlock: height must be a positive integer, got ${height}`) } const config = await context.config() const tzkt = createTzktApi(config) const [ block, parentBlock, transactions, tokenTransfers, delegations, stakings, originations, reveals, ] = await Promise.all([ tzkt.getBlockByLevel(height), tzkt.getBlockByLevel(height - 1), fetchBlockTransactions(config, height), fetchBlockTokenTransfers(config, height), fetchBlockDelegations(config, height), fetchBlockStaking(config, height), fetchBlockOriginations(config, height), fetchBlockReveals(config, height), ]) // Token transfers triggered by originations from other blocks carry an // `originationId` that points outside this block's origination set. Resolve // those hashes so the token ops attach to the correct parent BlockTransaction // instead of becoming orphan `token-{id}` entries. const knownIds = new Set([...transactions.map((t) => t.id), ...originations.map((o) => o.id)]) const unresolvedOrigIds = [ ...new Set( tokenTransfers .filter((t) => t.transactionId === undefined) .map((t) => t.originationId) .filter((id): id is number => id !== undefined && !knownIds.has(id)) ), ] const crossBlockIdToHash = new Map<number, string>() if (unresolvedOrigIds.length > 0) { const resolved = await tzkt.getOperationsOrigination(0, undefined, { 'id.in': unresolvedOrigIds.join(','), }) for (const op of resolved) { if (op.id && op.hash) crossBlockIdToHash.set(op.id, op.hash) } } return { info: mapBlockInfo(block, parentBlock), transactions: groupAndMapTransactions( transactions, tokenTransfers, delegations, stakings, originations, reveals, crossBlockIdToHash ), } }