UNPKG

@ledgerhq/coin-tezos

Version:
561 lines (515 loc) 20.4 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { TezosCoinConfig } from '../config' import type { APIAccount, APIBlock, APIDelegationType, APIOperation, APIOriginationType, APIRevealType, APIStakingType, APITokenTransfer, APITransactionType, APIUnstakeRequest, AccountsGetOperationsOptions, TokenTransfersGetOptions, APITokenBalance, } from './types' import URL from 'url' import network from '@ledgerhq/live-network' import { log } from '@ledgerhq/logs' /** TzKT hard-caps `limit` at 10 000; we use a safer page size to stay well under that. */ const BLOCK_PAGE_SIZE = 1000 /** Maximum number of IDs per `id.in` request to keep URLs under the Cloudflare proxy limit (~16 KB). */ const ID_IN_CHUNK_SIZE = 100 /** * Coalesces concurrent `getAccountByAddress` calls for the same explorer+address into a * single request. During a sync both `getBalance` and the readiness hook hit this endpoint * in the same tick; the in-flight promise is dropped on settle, so there is no staleness. */ const accountByAddressInflight = new Map<string, Promise<APIAccount>>() const clearUndefined = (obj: Record<string, unknown>) => { const newObj = { ...obj } Object.entries(newObj).forEach(([key, value]) => value === undefined && delete newObj[key]) return newObj } /** Splits an array into chunks of at most `size` elements. */ function chunk<T>(arr: T[], size: number): T[][] { const result: T[][] = [] for (let i = 0; i < arr.length; i += size) { result.push(arr.slice(i, i + size)) } return result } /** * Builds the TzKT explorer client bound to the given coin config (ADR-019). The caller resolves the * config from its {@link Context} and passes it in, rather than the client reading a module-level * singleton. */ export function createTzktApi(config: TezosCoinConfig) { const explorerUrl = config.explorer.url /** * Internal helper shared by `getOperationsTransactions` and `getOperationsOrigination`. * Both endpoints accept the same query shape; only the URL path differs. */ async function getOperationsByType( type: 'transactions' | 'originations', level: number, cursor?: number, apiQueryParams: Record<string, unknown> = {} ): Promise<(APITransactionType & { block: string; hash: string })[]> { // "sort.asc": "id" guarantees forward progress for cursor-based paging (offset.cr). // Without an explicit sort the API default may be descending, which would cause the // cursor to go backwards and produce duplicates or an infinite loop. const params: Record<string, unknown> = { 'level.gte': level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id', ...clearUndefined(apiQueryParams), } if (cursor !== undefined) params['offset.cr'] = cursor const { data } = await network<(APITransactionType & { block: string; hash: string })[]>({ url: `${explorerUrl}/v1/operations/${type}`, params, }) return data } const api = { async getBlockCount(): Promise<number> { const { data } = await network<number>({ url: `${explorerUrl}/v1/blocks/count`, }) return data }, async getLastBlock(): Promise<{ hash: string; level: number; date: Date }> { const { data } = await network<APIBlock[]>({ url: `${explorerUrl}/v1/blocks`, params: { 'sort.desc': 'level', }, }) return { hash: data[0].hash, level: data[0].level, date: new Date(data[0].timestamp), } }, async getAccountByAddress(address: string): Promise<APIAccount> { const key = `${explorerUrl}|${address}` const existing = accountByAddressInflight.get(key) if (existing) return existing const request = network<APIAccount>({ url: `${explorerUrl}/v1/accounts/${address}`, }) .then(({ data }) => data) .finally(() => accountByAddressInflight.delete(key)) accountByAddressInflight.set(key, request) return request }, /** * Returns the total `actualAmount` (mutez) summed over the account's `finalizable` * unstake requests — the portion of `unstakedBalance` whose unlock cycle has been * reached and that can be reclaimed via `finalize_unstake`. The complementary * `unstakedBalance - finalizable` portion is still in the deactivation delay window. * * TzKT's account endpoint does not expose this split, so we sum from * `/v1/staking/unstake_requests`. https://api.tzkt.io/#operation/Staking_GetUnstakeRequests * * `limit=1000` covers any realistic number of concurrent finalizable requests for * a single staker — we do not paginate. */ async getUnstakeRequestsFinalizable(address: string): Promise<bigint> { const { data } = await network<number[]>({ url: `${explorerUrl}/v1/staking/unstake_requests`, params: { 'staker.eq': address, status: 'finalizable', 'select.values': 'actualAmount', limit: 1000, }, }) return data.reduce<bigint>((sum, n) => sum + BigInt(n), 0n) }, /** * Pending + finalizable unstake requests, by id ascending. Uses `status.ne=finalized` because * TzKT ignores `status.in=pending,finalizable` on this endpoint and returns finalized requests. * https://api.tzkt.io/#operation/Staking_GetUnstakeRequests */ async getUnstakeRequests(address: string): Promise<APIUnstakeRequest[]> { const { data } = await network<APIUnstakeRequest[]>({ url: `${explorerUrl}/v1/staking/unstake_requests`, params: { 'staker.eq': address, 'status.ne': 'finalized', 'sort.asc': 'id', limit: 1000, }, }) return data }, // https://api.tzkt.io/#operation/Accounts_GetOperations async getAccountOperations( address: string, query: AccountsGetOperationsOptions ): Promise<APIOperation[]> { // Remove undefined from query Object.entries(query).forEach( ([key, value]) => value === undefined && delete query[key as keyof typeof query] ) const { data } = await network<APIOperation[]>({ url: URL.format({ pathname: `${explorerUrl}/v1/accounts/${address}/operations`, query, }), }) return data }, // https://api.tzkt.io/#operation/Blocks_GetByLevel async getBlockByLevel(level: number): Promise<APIBlock> { const { data } = await network<APIBlock>({ url: `${explorerUrl}/v1/blocks/${level}`, }) return data }, /** * Resolves block hashes for the given levels in a single request. * Uses `/v1/blocks?level.in=...&select.values=level,hash`, which TzKT honours * (unlike `/v1/blocks/{level}?select=...`, where `select` is ignored). Used * for cheap backfill of `block.hash` on operations whose level is known but * whose response omits the block field (e.g. `/accounts/{addr}/operations` * for staking ops). Levels that don't resolve are absent from the result map. */ async getBlockHashesByLevels(levels: readonly number[]): Promise<Map<number, string>> { if (levels.length === 0) return new Map() const { data } = await network<[number, string][]>({ url: `${explorerUrl}/v1/blocks`, params: { 'level.in': levels.join(','), 'select.values': 'level,hash', limit: levels.length, }, }) return new Map(data) }, /** * Fetches a single page of `transaction` operations at the given block level. * Internal — used by `fetchBlockTransactions` which handles pagination. * https://api.tzkt.io/#operation/Operations_GetTransactions */ async getBlockTransactionsPage(level: number, cursor?: number): Promise<APITransactionType[]> { // "sort.asc": "id" guarantees forward progress for cursor-based paging (offset.cr). // Without an explicit sort the API default may be descending, which would cause the // cursor to go backwards and produce duplicates or an infinite loop. const params: Record<string, unknown> = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' } if (cursor !== undefined) params['offset.cr'] = cursor const { data } = await network<APITransactionType[]>({ url: `${explorerUrl}/v1/operations/transactions`, params, }) return data }, /** * Fetches a list of `transaction` operations after the given level. * https://api.tzkt.io/#operation/Operations_GetTransactions */ async getOperationsTransactions( level: number, cursor?: number, apiQueryParams: Record<string, unknown> = {} ): Promise<(APITransactionType & { block: string; hash: string })[]> { return getOperationsByType('transactions', level, cursor, apiQueryParams) }, /** * Fetches a list of `originations` operations after the given level. * https://api.tzkt.io/#operation/Operations_GetOriginations */ async getOperationsOrigination( level: number, cursor?: number, apiQueryParams: Record<string, unknown> = {} ): Promise<(APITransactionType & { block: string; hash: string })[]> { return getOperationsByType('originations', level, cursor, apiQueryParams) }, /** * Fetches a single page of FA token transfers at the given block level. * Internal — used by `fetchBlockTokenTransfers` which handles pagination. * https://api.tzkt.io/#operation/Tokens_GetTokenTransfers */ async getBlockTokenTransfersPage(level: number, cursor?: number): Promise<APITokenTransfer[]> { // Same rationale as getBlockTransactionsPage: explicit ascending sort keeps the // offset.cr cursor advancing forward regardless of the API's default ordering. const params: Record<string, unknown> = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id', 'token.standard': 'fa2', } if (cursor !== undefined) params['offset.cr'] = cursor const { data } = await network<APITokenTransfer[]>({ url: `${explorerUrl}/v1/tokens/transfers`, params, }) return data }, /** * Fetches the latest FA token transfers since the given level. * https://api.tzkt.io/#operation/Tokens_GetTokenTransfers */ async getTokenTransfers( apiQueryParams: Record<string, unknown> = {} ): Promise<APITokenTransfer[]> { const params = { ...clearUndefined(apiQueryParams), } const { data } = await network<APITokenTransfer[]>({ url: `${explorerUrl}/v1/tokens/transfers`, params, }) return data }, /** * Fetches a single page of `origination` operations at the given block level. * Internal — used by `fetchBlockOriginations` which handles pagination. * https://api.tzkt.io/#operation/Operations_GetOriginations */ async getBlockOriginationsPage(level: number, cursor?: number): Promise<APIOriginationType[]> { const params: Record<string, unknown> = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' } if (cursor !== undefined) params['offset.cr'] = cursor const { data } = await network<APIOriginationType[]>({ url: `${explorerUrl}/v1/operations/originations`, params, }) return data }, /** * Fetches a single page of `reveal` operations at the given block level. * Internal — used by `fetchBlockReveals` which handles pagination. * https://api.tzkt.io/#operation/Operations_GetReveals */ async getBlockRevealsPage(level: number, cursor?: number): Promise<APIRevealType[]> { const params: Record<string, unknown> = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' } if (cursor !== undefined) params['offset.cr'] = cursor const { data } = await network<APIRevealType[]>({ url: `${explorerUrl}/v1/operations/reveals`, params, }) return data }, /** * Fetches a single page of `delegation` operations at the given block level. * Internal — used by `fetchBlockDelegations` which handles pagination. * https://api.tzkt.io/#operation/Operations_GetDelegations */ async getBlockDelegationsPage(level: number, cursor?: number): Promise<APIDelegationType[]> { const params: Record<string, unknown> = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' } if (cursor !== undefined) params['offset.cr'] = cursor const { data } = await network<APIDelegationType[]>({ url: `${explorerUrl}/v1/operations/delegations`, params, }) return data }, /** * Fetches a single page of `staking` operations at the given block level. * Internal — used by `fetchBlockStaking` which handles pagination. * https://api.tzkt.io/#operation/Operations_GetStaking */ async getBlockStakingPage(level: number, cursor?: number): Promise<APIStakingType[]> { const params: Record<string, unknown> = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' } if (cursor !== undefined) params['offset.cr'] = cursor const { data } = await network<APIStakingType[]>({ url: `${explorerUrl}/v1/operations/staking`, params, }) return data }, /** * Fetches FA2 token transfers for a given account. * Translates `query.sort` to TzKT's `sort.asc=id` / `sort.desc=id`. * The lower-level `getTokenTransfers` helper is a generic pass-through and does not pin the sort. * https://api.tzkt.io/#operation/Tokens_GetTokenTransfers */ async getAccountTokenTransfers( address: string, query: TokenTransfersGetOptions ): Promise<(APITokenTransfer & { hash: string; block: string })[]> { const sortKey = query.sort === 'Descending' ? 'sort.desc' : 'sort.asc' const params: Record<string, unknown> = { 'anyof.from.to': address, 'token.standard': 'fa2', [sortKey]: 'id', limit: query.limit, 'level.ge': query['level.ge'], 'level.lt': query['level.lt'], 'level.gt': query['level.gt'], 'id.lt': query['id.lt'], 'id.gt': query['id.gt'], } const data = await api.getTokenTransfers(clearUndefined(params)) const transactionIds = data .map((t) => t.transactionId) .filter((id): id is number => typeof id === 'number') const originationIds = data .map((t) => t.originationId) .filter((id): id is number => typeof id === 'number') if (transactionIds.length === 0 && originationIds.length === 0) { return [] } const transactions = transactionIds.length ? ( await Promise.all( chunk(transactionIds, ID_IN_CHUNK_SIZE).map((ids) => api.getOperationsTransactions(query['level.ge'] || 0, undefined, { 'id.in': ids.join(','), }) ) ) ).flat() : [] const originations = originationIds.length ? ( await Promise.all( chunk(originationIds, ID_IN_CHUNK_SIZE).map((ids) => api.getOperationsOrigination(query['level.ge'] || 0, undefined, { 'id.in': ids.join(','), }) ) ) ).flat() : [] // Build id -> operation maps once so per-transfer lookups are O(1) instead of O(n). // Keys are widened to `number | undefined` so lookups with a missing id naturally // return `undefined` (no entry is ever stored under the `undefined` key). const transactionsById = new Map<number | undefined, (typeof transactions)[number]>( transactions.map((t) => [t.id, t]) ) const originationsById = new Map<number | undefined, (typeof originations)[number]>( originations.map((o) => [o.id, o]) ) return data.map((token) => { const transaction = transactionsById.get(token.transactionId) const origination = originationsById.get(token.originationId) return { ...token, hash: transaction?.hash ?? origination?.hash ?? '', block: transaction?.block ?? origination?.block ?? '', } }) }, /** * Fetches FA2 token balances for a given account. * When `tokenFilter` is omitted, all FA2 token balances are returned. * Pass `tokenFilter` to query a specific FA2 contract + token id (e.g. send-max for FA2). * https://api.tzkt.io/#operation/Tokens_GetTokenBalances */ async getTokensBalances( address: string, tokenFilter?: { contractAddress: string; tokenId: number } ): Promise<APITokenBalance[]> { const params: Record<string, unknown> = { account: address, 'token.standard': 'fa2', } if (tokenFilter) { params['token.contract'] = tokenFilter.contractAddress params['token.tokenId'] = String(tokenFilter.tokenId) } const { data } = await network<APITokenBalance[]>({ url: `${explorerUrl}/v1/tokens/balances`, params, }) return data }, } return api } /** The TzKT explorer client bound to a coin config, as returned by {@link createTzktApi}. */ export type TzktApi = ReturnType<typeof createTzktApi> // TODO this has same purpose as api/listOperations export const fetchAllTransactions = async ( config: TezosCoinConfig, address: string, lastId?: number ): Promise<APIOperation[]> => { const api = createTzktApi(config) let ops: APIOperation[] = [] let maxIteration = config.explorer.maxTxQuery do { const newOps = await api.getAccountOperations(address, { lastId, sort: 'Ascending', 'level.ge': 0, }) if (newOps.length === 0) return ops ops = ops.concat(newOps) const last = ops[ops.length - 1] if (!last) return ops lastId = last.id if (!lastId) { log('tezos', 'id missing!') return ops } } while (--maxIteration) return ops } /** * Generic paginated fetcher for block-level operations. * * TzKT hard-caps a single request at 10 000 items. This function issues multiple * requests when needed and is therefore safe for dense blocks. * A safety cap (`maxTxQuery`) prevents infinite loops on pathological responses. */ async function fetchBlockPaginated<T extends { id: number }>( config: TezosCoinConfig, pageFn: (level: number, cursor?: number) => Promise<T[]>, level: number, label: string ): Promise<T[]> { const items: T[] = [] let cursor: number | undefined let maxIteration = config.explorer.maxTxQuery do { const page = await pageFn(level, cursor) if (page.length === 0) break items.push(...page) if (page.length < BLOCK_PAGE_SIZE) break cursor = page.at(-1)!.id } while (--maxIteration > 0) if (maxIteration === 0) { log('tezos', `${label}: maxTxQuery limit reached at level ${level}, result may be incomplete`) } return items } export const fetchBlockTransactions = (config: TezosCoinConfig, level: number) => fetchBlockPaginated( config, createTzktApi(config).getBlockTransactionsPage, level, 'fetchBlockTransactions' ) export const fetchBlockTokenTransfers = (config: TezosCoinConfig, level: number) => fetchBlockPaginated( config, createTzktApi(config).getBlockTokenTransfersPage, level, 'fetchBlockTokenTransfers' ) export const fetchBlockDelegations = (config: TezosCoinConfig, level: number) => fetchBlockPaginated( config, createTzktApi(config).getBlockDelegationsPage, level, 'fetchBlockDelegations' ) export const fetchBlockStaking = (config: TezosCoinConfig, level: number) => fetchBlockPaginated(config, createTzktApi(config).getBlockStakingPage, level, 'fetchBlockStaking') export const fetchBlockOriginations = (config: TezosCoinConfig, level: number) => fetchBlockPaginated( config, createTzktApi(config).getBlockOriginationsPage, level, 'fetchBlockOriginations' ) export const fetchBlockReveals = (config: TezosCoinConfig, level: number) => fetchBlockPaginated(config, createTzktApi(config).getBlockRevealsPage, level, 'fetchBlockReveals')