UNPKG

@ledgerhq/coin-tezos

Version:
514 lines 21.1 kB
"use strict"; // SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 Object.defineProperty(exports, "__esModule", { value: true }); exports.listOperations = listOperations; const logs_1 = require("@ledgerhq/logs"); const constants_1 = require("../constants"); const network_1 = require("../network"); const types_1 = require("../network/types"); function parseCursor(token) { if (!token) return undefined; try { const parsed = JSON.parse(token); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const o = parsed; if (typeof o.lastLevel === 'number' && Number.isFinite(o.lastLevel)) { const cursor = { lastLevel: o.lastLevel }; if (typeof o.nativeLastId === 'number' && Number.isFinite(o.nativeLastId)) { cursor.nativeLastId = o.nativeLastId; } if (typeof o.tokenLastId === 'number' && Number.isFinite(o.tokenLastId)) { cursor.tokenLastId = o.tokenLastId; } return cursor; } } } catch { // ignore invalid cursor } return undefined; } function minLevel(items) { if (items.length === 0) return undefined; return Math.min(...items.map((i) => i.level)); } function maxLevel(items) { if (items.length === 0) return undefined; return Math.max(...items.map((i) => i.level)); } /** When the API returned a full page, the trailing block level may be incomplete — drop it. */ function trimPartialLastLevel(items, full) { if (!full || items.length === 0) return { trimmed: items }; const last = items.at(-1); if (!last) return { trimmed: items }; const lastLevel = last.level; const filtered = items.filter((op) => op.level !== lastLevel); if (filtered.length === 0) { (0, logs_1.log)('coin:tezos', 'listOperations: full page single level — keeping all rows', { lastLevel, count: items.length, }); return { trimmed: items, intraLevelLastId: last.id }; } return { trimmed: filtered }; } function buildNativeOptions(sort, cursor, minHeight) { if (!cursor) { return { sort, 'level.ge': minHeight }; } const { lastLevel, nativeLastId } = cursor; if (sort === 'Descending') { if (nativeLastId !== undefined) { return { sort, 'level.ge': minHeight, 'level.lt': lastLevel + 1, lastId: nativeLastId, }; } return { sort, 'level.ge': minHeight, 'level.lt': lastLevel, }; } if (nativeLastId !== undefined) { return { sort, 'level.ge': Math.max(minHeight, lastLevel), lastId: nativeLastId, }; } return { sort, 'level.ge': Math.max(minHeight, lastLevel + 1), }; } function buildTokenOptions(sort, cursor, minHeight) { if (!cursor) { return { sort, 'level.ge': minHeight }; } const { lastLevel, tokenLastId } = cursor; if (sort === 'Descending') { if (tokenLastId !== undefined) { return { sort, 'level.ge': minHeight, 'level.lt': lastLevel + 1, 'id.lt': tokenLastId, }; } return { sort, 'level.ge': minHeight, 'level.lt': lastLevel, }; } if (tokenLastId !== undefined) { return { sort, 'level.ge': Math.max(minHeight, lastLevel), 'id.gt': tokenLastId, }; } return { sort, 'level.ge': Math.max(minHeight, lastLevel + 1), }; } function computeBoundary(nativeTrim, tokenTrim, sort) { if (sort === 'Descending') { const mn = minLevel(nativeTrim); const mt = minLevel(tokenTrim); if (mn === undefined && mt === undefined) return undefined; if (mn === undefined) return mt; if (mt === undefined) return mn; return Math.max(mn, mt); } const xn = maxLevel(nativeTrim); const xt = maxLevel(tokenTrim); if (xn === undefined && xt === undefined) return undefined; if (xn === undefined) return xt; if (xt === undefined) return xn; return Math.min(xn, xt); } function alignToBoundary(items, boundary, sort) { if (sort === 'Descending') { return items.filter((op) => op.level >= boundary); } return items.filter((op) => op.level <= boundary); } function clampPage(raw, limit) { if (limit === undefined) { return { sliced: raw, full: false }; } return { sliced: raw.slice(0, limit), full: raw.length >= limit, }; } function buildParentMap(ops) { const parentMap = new Map(); for (const op of ops) { if ((0, types_1.isAPITransactionType)(op) && op.id !== null) { parentMap.set(op.id, op); } } return parentMap; } function keepNativeOp(op, address) { if (!((0, types_1.isAPITransactionType)(op) || (0, types_1.isAPIDelegationType)(op) || (0, types_1.isAPIRevealType)(op) || (0, types_1.isAPIStakingType)(op) || (0, types_1.isAPIOriginationType)(op))) { return false; } if (op.status !== 'applied' && (0, types_1.isAPITransactionType)(op)) { const isIn = op.target?.address === address; if (isIn) { return false; } } // Filter out internal (contract-to-contract/contract-to-third-party) transactions: // TzKT returns these under the initiator's account, but they don't impact the // initiator's native balance — the top-level operation already accounts for it. if ((0, types_1.isAPITransactionType)(op)) { const isSender = op.sender?.address === address; const isTarget = op.target?.address === address; if (!isSender && !isTarget) { return false; } } return true; } function convertNativeOps(ops, address, stakingBlockHashes) { // Accumulate fees from internal sub-txs that will be filtered out. // On Tezos, when an account initiates a contract call, internal sub-operations // may incur storage/baker fees charged to the initiator. These sub-txs are // filtered by keepNativeOp (account is neither sender nor target), but their // fees still impact the initiator's balance and must be attributed to the // top-level operation. const internalFeesByHash = new Map(); for (const op of ops) { if ((0, types_1.isAPITransactionType)(op) && op.initiator?.address === address && op.sender?.address !== address && op.target?.address !== address) { const hash = op.hash; if (!hash) continue; const fee = BigInt(op.storageFee ?? 0) + BigInt(op.bakerFee ?? 0) + BigInt(op.allocationFee ?? 0); if (fee > 0n) { internalFeesByHash.set(hash, (internalFeesByHash.get(hash) ?? 0n) + fee); } } } return ops .filter((op) => keepNativeOp(op, address)) .map((op) => { const converted = convertOperation(address, op, stakingBlockHashes); const extraFee = internalFeesByHash.get(converted.tx.hash); if (extraFee) { // Add internal sub-tx fees to the parent operation, then clear to avoid // attributing the same fees to multiple ops sharing the same hash. internalFeesByHash.delete(converted.tx.hash); return { ...converted, tx: { ...converted.tx, fees: converted.tx.fees + extraFee } }; } return converted; }); } function convertTokenOps(transfers, parentMap, address) { return transfers.map((transfer) => convertTokenOperation(address, transfer, transfer.transactionId ? parentMap.get(transfer.transactionId) : undefined)); } function computeNextToken(boundary, nativeFull, tokenFull, hasResults, nativeIntraLastId, tokenIntraLastId) { const shouldEmitNext = boundary !== undefined && (nativeFull || tokenFull) && hasResults; if (!shouldEmitNext) return ''; const payload = { lastLevel: boundary }; if (nativeIntraLastId !== undefined) payload.nativeLastId = nativeIntraLastId; if (tokenIntraLastId !== undefined) payload.tokenLastId = tokenIntraLastId; return JSON.stringify(payload); } /** * Returns list of "Transfer", "Delegate" and "Undelegate" Operations associated to an account. * @param address Account address * @param limit the maximum number of operations to return. Beware that's a weak limit, as explorers might not respect it. * @param order whether to return operations starting from the top block or from the oldest block. * "Descending" returns newest operation first, "Ascending" returns oldest operation first. * It doesn't control the order of the operations in the result list: * operations are always returned sorted in descending order (newest first). * @param minHeight retrieve operations from a specific block height until top most (inclusive). * @param token a token to be used for pagination * @returns a list of operations is descending (newest first) order and a token to be used for pagination */ async function listOperations(context, address, { token, limit, sort, minHeight, }) { const config = await context.config(); const tzkt = (0, network_1.createTzktApi)(config); const cursor = parseCursor(token); const nativeOptions = { limit, ...buildNativeOptions(sort, cursor, minHeight), }; const tokenOptions = { limit, ...buildTokenOptions(sort, cursor, minHeight), }; const [nativeOpsRaw, tokenTransfersRaw] = await Promise.all([ tzkt.getAccountOperations(address, nativeOptions), tzkt.getAccountTokenTransfers(address, tokenOptions), ]); const nativePage = clampPage(nativeOpsRaw, limit); const tokenPage = clampPage(tokenTransfersRaw, limit); const nativeTrimmed = trimPartialLastLevel(nativePage.sliced, nativePage.full); const tokenTrimmed = trimPartialLastLevel(tokenPage.sliced, tokenPage.full); // when both streams are exhausted (neither hit the page limit), // we already have every op — alignment would silently discard ops // beyond the boundary with no cursor to retrieve them later. Skip it. const bothExhausted = !nativePage.full && !tokenPage.full; const boundary = bothExhausted ? undefined : computeBoundary(nativeTrimmed.trimmed, tokenTrimmed.trimmed, sort); const nativeAligned = boundary === undefined ? nativeTrimmed.trimmed : alignToBoundary(nativeTrimmed.trimmed, boundary, sort); const tokenAligned = boundary === undefined ? tokenTrimmed.trimmed : alignToBoundary(tokenTrimmed.trimmed, boundary, sort); const stakingBlockHashes = await fetchMissingStakingBlockHashes(config, nativeAligned); const parentMap = buildParentMap(nativeAligned); const nextToken = computeNextToken(boundary, nativePage.full, tokenPage.full, nativeAligned.length > 0 || tokenAligned.length > 0, nativeTrimmed.intraLevelLastId, tokenTrimmed.intraLevelLastId); const filteredNativeOps = convertNativeOps(nativeAligned, address, stakingBlockHashes); const tokenConverted = convertTokenOps(tokenAligned, parentMap, address); const sortedOperations = [...filteredNativeOps, ...tokenConverted].sort((a, b) => b.tx.date.getTime() - a.tx.date.getTime()); return [sortedOperations, nextToken]; } /** * TzKT omits `block` on staking ops returned by /accounts/{addr}/operations. * Returns a level → block-hash map covering exactly those ops, so the caller * can populate `tx.block.hash` without mutating the API response. Network * failures are swallowed: callers fall back to `""` for levels not in the map. */ async function fetchMissingStakingBlockHashes(config, ops) { const missingLevels = new Set(); for (const op of ops) { if ((0, types_1.isAPIStakingType)(op) && !op.block) missingLevels.add(op.level); } if (missingLevels.size === 0) return new Map(); try { return await (0, network_1.createTzktApi)(config).getBlockHashesByLevels([...missingLevels]); } catch (err) { (0, logs_1.log)('coin:tezos', 'fetchMissingStakingBlockHashes: skipped on fetch error', { reason: String(err), }); return new Map(); } } function resolveBlockHash(operation, stakingBlockHashes) { const fromOp = typeof operation.block === 'string' ? operation.block : operation.block?.hash; return fromOp ?? stakingBlockHashes.get(operation.level) ?? ''; } function resolveTargetAddress(operation) { if ((0, types_1.isAPITransactionType)(operation)) return operation.target?.address; if ((0, types_1.isAPIOriginationType)(operation)) return operation.originatedContract?.address; if ((0, types_1.isAPIDelegationType)(operation)) { return operation.newDelegate?.address || operation.prevDelegate?.address; } if ((0, types_1.isAPIStakingType)(operation)) return operation.baker?.address; return undefined; } // finalize_unstake's protocol sender is the gas payer (anyone may call it), // not the staker; map staking ops from the staker's perspective so the op // stays visible in their wallet. function resolveStakingAddresses(op) { const stakerAddr = op.staker?.address ?? op.sender?.address; const bakerAddr = op.baker?.address; const stakerArr = stakerAddr ? [stakerAddr] : []; const bakerArr = bakerAddr ? [bakerAddr] : []; if (op.action === 'finalize') return { senders: bakerArr, recipients: stakerArr }; return { senders: stakerArr, recipients: bakerArr }; } // Failed stake/unstake ops carry `requestedAmount` and no `amount` field; // `BigInt(undefined)` throws and breaks sync. Fall back per-shape. function resolveAmount(operation) { if ((0, types_1.isAPIRevealType)(operation) || (0, types_1.isAPIDelegationType)(operation)) return 0n; if ((0, types_1.isAPIOriginationType)(operation)) return BigInt(operation.contractBalance ?? 0); if ((0, types_1.isAPIStakingType)(operation)) { return BigInt(operation.amount ?? operation.requestedAmount ?? 0); } return BigInt(operation.amount ?? 0); } function resolveNormalizedType(operation, address, targetAddress, amount) { if ((0, types_1.isAPIDelegationType)(operation)) { return operation.newDelegate?.address ? 'DELEGATE' : 'UNDELEGATE'; } if ((0, types_1.isAPIStakingType)(operation)) return constants_1.STAKING_ACTION_TO_OP_TYPE[operation.action]; if ((0, types_1.isAPIRevealType)(operation)) return 'REVEAL'; if ((0, types_1.isAPIOriginationType)(operation)) return operation.contractBalance > 0 ? 'OUT' : 'FEES'; if (!(0, types_1.isAPITransactionType)(operation)) { (0, logs_1.log)('coin:tezos', '(logic/operations): Unknown operation type, defaulting to OUT'); return 'OUT'; } const isOut = operation.sender?.address === address; const isIn = targetAddress === address; if ((isOut && isIn) || amount === 0n) return 'FEES'; if (isOut) return 'OUT'; if (isIn) return 'IN'; return 'OUT'; } function getLedgerOpType(operation, normalizedType) { if ((0, types_1.isAPIDelegationType)(operation)) { return operation.newDelegate?.address ? 'DELEGATE' : 'UNDELEGATE'; } else if ((0, types_1.isAPIRevealType)(operation)) { return 'REVEAL'; } else if ((0, types_1.isAPIOriginationType)(operation)) { return 'ORIGINATION'; } else if ((0, types_1.isAPIStakingType)(operation)) { return constants_1.STAKING_ACTION_TO_OP_TYPE[operation.action]; } else if (normalizedType === 'FEES') { return 'FEES'; } return undefined; } function convertOperation(address, operation, stakingBlockHashes) { const { hash, sender, id } = operation; // For transactions, the initiator (if present) is the fee payer (internal/sub-operations triggered by contracts). // Otherwise, the sender is the fee payer. For delegation/reveal/staking there is no initiator; sender is the fee payer. const feesPayer = (0, types_1.isAPITransactionType)(operation) ? (operation.initiator?.address ?? sender?.address) : sender?.address; const targetAddress = resolveTargetAddress(operation); // reveal has no meaningful target; staking resolves senders/recipients separately. if (!targetAddress && !(0, types_1.isAPIRevealType)(operation) && !(0, types_1.isAPIStakingType)(operation)) { (0, logs_1.log)('coin:tezos', '(logic/operations): No target address found for operation', operation); } const { senders, recipients } = (0, types_1.isAPIStakingType)(operation) ? resolveStakingAddresses(operation) : { senders: sender?.address ? [sender.address] : [], recipients: targetAddress ? [targetAddress] : [], }; const amount = resolveAmount(operation); const fee = BigInt(operation.storageFee ?? 0) + BigInt(operation.bakerFee ?? 0) + BigInt(operation.allocationFee ?? 0); const normalizedType = resolveNormalizedType(operation, address, targetAddress, amount); // Tezos uses "applied" for every success operation (something else=failed ) const hasFailed = operation.status && operation.status !== 'applied'; return { id: `${hash ?? ''}-${id}`, asset: { type: 'native' }, tx: { // hash id defined nullable in the tzkt API, but I wonder when it would be null ? hash: hash ?? '', // storageFee for transaction is always present fees: BigInt(fee ?? 0), ...(feesPayer ? { feesPayer } : {}), block: { hash: resolveBlockHash(operation, stakingBlockHashes), height: operation.level, time: new Date(operation.timestamp), }, date: new Date(operation.timestamp), failed: hasFailed ?? false, }, type: normalizedType, value: amount, senders: senders, recipients: recipients, details: { counter: operation.counter, gasLimit: operation.gasLimit, storageLimit: operation.storageLimit, ledgerOpType: getLedgerOpType(operation, normalizedType), }, }; } function convertTokenOperation(address, transfer, parent) { const isOut = transfer.from?.address === address; const isIn = transfer.to?.address === address; let type = 'FEES'; if (isOut && !isIn) { type = 'OUT'; } else if (isIn && !isOut) { type = 'IN'; } const feesPayer = parent?.initiator?.address ?? parent?.sender?.address; const tokenId = transfer.token.tokenId ?? '0'; const assetReference = `${transfer.token.contract.address}:${tokenId}`; return { id: `${transfer.hash}-token-${transfer.id}`, type, senders: transfer.from?.address ? [transfer.from.address] : [], recipients: transfer.to?.address ? [transfer.to.address] : [], value: BigInt(transfer.amount), asset: { type: transfer.token.standard, assetReference, assetOwner: address, unit: { magnitude: Number.parseInt(transfer.token.metadata?.decimals ?? '0', 10), name: transfer.token.metadata?.name ?? '', code: transfer.token.metadata?.symbol ?? '', }, }, tx: { hash: transfer.hash, // Fee is already on the parent native operation; setting 0 avoids // double-counting when A4 sums fees across sub-operations (FeeAggregationMode.Sum). fees: 0n, ...(feesPayer ? { feesPayer } : {}), block: { hash: transfer.block ?? parent?.block ?? '', height: transfer.level, time: new Date(transfer.timestamp), }, date: new Date(transfer.timestamp), failed: parent ? Boolean(parent.status && parent.status !== 'applied') : false, }, details: { ledgerOpType: type, assetAmount: transfer.amount, assetSenders: transfer.from?.address ? [transfer.from.address] : [], assetRecipients: transfer.to?.address ? [transfer.to.address] : [], parentSenders: parent?.sender?.address ? [parent.sender.address] : [], parentRecipients: parent?.target?.address ? [parent.target.address] : [], }, }; } //# sourceMappingURL=listOperations.js.map