UNPKG

@ledgerhq/coin-tezos

Version:
468 lines 18.3 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import { STAKING_ACTION_TO_OP_TYPE } from '../constants'; import { createTzktApi, fetchBlockDelegations, fetchBlockOriginations, fetchBlockReveals, fetchBlockStaking, fetchBlockTokenTransfers, fetchBlockTransactions, } from '../network'; const NATIVE_ASSET = { type: 'native', name: 'XTZ' }; // --------------------------------------------------------------------------- // Block-info mapping // --------------------------------------------------------------------------- function mapBlockInfo(block, parentBlock) { 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) { 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) { 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) { 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) { 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) { const ops = []; 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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 = { 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 = []; 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) { const groups = new Map(); 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, group) { const succeeded = isGroupSucceeded(group); const blockTx = { 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, txIdToHash, blockTxByHash, standaloneByKey) { 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, auxTx) { 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, tokenTransfers, delegations, stakings, originations, reveals, crossBlockIdToHash = new Map()) { const groups = groupTransactionsByHash(transactions); const txIdToHash = new Map(); 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(); 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(); 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, height) { 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 !== undefined && !knownIds.has(id))), ]; const crossBlockIdToHash = new Map(); 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), }; } //# sourceMappingURL=getBlock.js.map