UNPKG

@ledgerhq/coin-tezos

Version:
658 lines (600 loc) 21.7 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { TezosCoinConfig, TezosContext } from '../config' import type { AccountsGetOperationsOptions } from '../network/types' import type { Operation } from '@ledgerhq/coin-module-framework/api/types' import { log } from '@ledgerhq/logs' import { STAKING_ACTION_TO_OP_TYPE } from '../constants' import { createTzktApi } from '../network' import { type APIDelegationType, type APIOperation, type APIOriginationType, type APIRevealType, type APIStakingType, type APITokenTransfer, type APITransactionType, isAPIDelegationType, isAPIOriginationType, isAPIRevealType, isAPIStakingType, isAPITransactionType, type TokenTransfersGetOptions, } from '../network/types' /** Block-level boundary + optional per-stream id cursors for intra-level continuation. */ type ListOperationsCursor = { lastLevel: number nativeLastId?: number tokenLastId?: number } function parseCursor(token?: string): ListOperationsCursor | undefined { if (!token) return undefined try { const parsed: unknown = JSON.parse(token) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const o = parsed as { lastLevel?: unknown nativeLastId?: unknown tokenLastId?: unknown } if (typeof o.lastLevel === 'number' && Number.isFinite(o.lastLevel)) { const cursor: ListOperationsCursor = { 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<T extends { level: number }>(items: T[]): number | undefined { if (items.length === 0) return undefined return Math.min(...items.map((i) => i.level)) } function maxLevel<T extends { level: number }>(items: T[]): number | undefined { 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<T extends { level: number; id: number }>( items: T[], full: boolean ): { trimmed: T[]; intraLevelLastId?: number } { 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) { 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: 'Ascending' | 'Descending', cursor: ListOperationsCursor | undefined, minHeight: number ): AccountsGetOperationsOptions { 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: 'Ascending' | 'Descending', cursor: ListOperationsCursor | undefined, minHeight: number ): TokenTransfersGetOptions { 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: { level: number }[], tokenTrim: { level: number }[], sort: 'Ascending' | 'Descending' ): number | undefined { 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<T extends { level: number }>( items: T[], boundary: number, sort: 'Ascending' | 'Descending' ): T[] { if (sort === 'Descending') { return items.filter((op) => op.level >= boundary) } return items.filter((op) => op.level <= boundary) } function clampPage<T>(raw: T[], limit?: number): { sliced: T[]; full: boolean } { if (limit === undefined) { return { sliced: raw, full: false } } return { sliced: raw.slice(0, limit), full: raw.length >= limit, } } function buildParentMap(ops: APIOperation[]): Map<number, APITransactionType> { const parentMap = new Map<number, APITransactionType>() for (const op of ops) { if (isAPITransactionType(op) && op.id !== null) { parentMap.set(op.id, op) } } return parentMap } function keepNativeOp(op: APIOperation, address: string): op is ConvertibleOperation { if ( !( isAPITransactionType(op) || isAPIDelegationType(op) || isAPIRevealType(op) || isAPIStakingType(op) || isAPIOriginationType(op) ) ) { return false } if (op.status !== 'applied' && 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 (isAPITransactionType(op)) { const isSender = op.sender?.address === address const isTarget = op.target?.address === address if (!isSender && !isTarget) { return false } } return true } function convertNativeOps( ops: APIOperation[], address: string, stakingBlockHashes: Map<number, string> ): Operation[] { // 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<string, bigint>() for (const op of ops) { if ( 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): op is ConvertibleOperation => 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: (APITokenTransfer & { hash: string; block?: string })[], parentMap: Map<number, APITransactionType>, address: string ): Operation[] { return transfers.map((transfer) => convertTokenOperation( address, transfer, transfer.transactionId ? parentMap.get(transfer.transactionId) : undefined ) ) } function computeNextToken( boundary: number | undefined, nativeFull: boolean, tokenFull: boolean, hasResults: boolean, nativeIntraLastId?: number, tokenIntraLastId?: number ): string { const shouldEmitNext = boundary !== undefined && (nativeFull || tokenFull) && hasResults if (!shouldEmitNext) return '' const payload: ListOperationsCursor = { 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 */ export async function listOperations( context: TezosContext, address: string, { token, limit, sort, minHeight, }: { limit?: number; token?: string; sort: 'Ascending' | 'Descending'; minHeight: number } ): Promise<[Operation[], string]> { const config = await context.config() const tzkt = createTzktApi(config) const cursor = parseCursor(token) const nativeOptions: AccountsGetOperationsOptions = { limit, ...buildNativeOptions(sort, cursor, minHeight), } const tokenOptions: TokenTransfersGetOptions = { 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] } type ConvertibleOperation = | APITransactionType | APIDelegationType | APIRevealType | APIStakingType | APIOriginationType /** * 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: TezosCoinConfig, ops: readonly APIOperation[] ): Promise<Map<number, string>> { const missingLevels = new Set<number>() for (const op of ops) { if (isAPIStakingType(op) && !op.block) missingLevels.add(op.level) } if (missingLevels.size === 0) return new Map() try { return await createTzktApi(config).getBlockHashesByLevels([...missingLevels]) } catch (err) { log('coin:tezos', 'fetchMissingStakingBlockHashes: skipped on fetch error', { reason: String(err), }) return new Map() } } function resolveBlockHash( operation: ConvertibleOperation, stakingBlockHashes: Map<number, string> ): string { const fromOp = typeof operation.block === 'string' ? operation.block : operation.block?.hash return fromOp ?? stakingBlockHashes.get(operation.level) ?? '' } function resolveTargetAddress(operation: ConvertibleOperation): string | undefined { if (isAPITransactionType(operation)) return operation.target?.address if (isAPIOriginationType(operation)) return operation.originatedContract?.address if (isAPIDelegationType(operation)) { return operation.newDelegate?.address || operation.prevDelegate?.address } if (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: APIStakingType): { senders: string[] recipients: string[] } { 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: ConvertibleOperation): bigint { if (isAPIRevealType(operation) || isAPIDelegationType(operation)) return 0n if (isAPIOriginationType(operation)) return BigInt(operation.contractBalance ?? 0) if (isAPIStakingType(operation)) { return BigInt(operation.amount ?? operation.requestedAmount ?? 0) } return BigInt(operation.amount ?? 0) } function resolveNormalizedType( operation: ConvertibleOperation, address: string, targetAddress: string | undefined, amount: bigint ): Operation['type'] { if (isAPIDelegationType(operation)) { return operation.newDelegate?.address ? 'DELEGATE' : 'UNDELEGATE' } if (isAPIStakingType(operation)) return STAKING_ACTION_TO_OP_TYPE[operation.action] if (isAPIRevealType(operation)) return 'REVEAL' if (isAPIOriginationType(operation)) return operation.contractBalance > 0 ? 'OUT' : 'FEES' if (!isAPITransactionType(operation)) { 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: ConvertibleOperation, normalizedType: Operation['type'] ): string | undefined { if (isAPIDelegationType(operation)) { return operation.newDelegate?.address ? 'DELEGATE' : 'UNDELEGATE' } else if (isAPIRevealType(operation)) { return 'REVEAL' } else if (isAPIOriginationType(operation)) { return 'ORIGINATION' } else if (isAPIStakingType(operation)) { return STAKING_ACTION_TO_OP_TYPE[operation.action] } else if (normalizedType === 'FEES') { return 'FEES' } return undefined } function convertOperation( address: string, operation: ConvertibleOperation, stakingBlockHashes: Map<number, string> ): Operation { 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 = 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 && !isAPIRevealType(operation) && !isAPIStakingType(operation)) { log('coin:tezos', '(logic/operations): No target address found for operation', operation) } const { senders, recipients } = 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: string, transfer: APITokenTransfer & { hash: string; block?: string }, parent?: APITransactionType ): Operation { const isOut = transfer.from?.address === address const isIn = transfer.to?.address === address let type: Operation['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] : [], }, } }