UNPKG

@ledgerhq/coin-tezos

Version:
448 lines 20.6 kB
"use strict"; // SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.fetchBlockReveals = exports.fetchBlockOriginations = exports.fetchBlockStaking = exports.fetchBlockDelegations = exports.fetchBlockTokenTransfers = exports.fetchBlockTransactions = exports.fetchAllTransactions = void 0; exports.createTzktApi = createTzktApi; const url_1 = __importDefault(require("url")); const live_network_1 = __importDefault(require("@ledgerhq/live-network")); const logs_1 = require("@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(); const clearUndefined = (obj) => { 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(arr, size) { const result = []; 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. */ function createTzktApi(config) { 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, level, cursor, apiQueryParams = {}) { // "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 = { 'level.gte': level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id', ...clearUndefined(apiQueryParams), }; if (cursor !== undefined) params['offset.cr'] = cursor; const { data } = await (0, live_network_1.default)({ url: `${explorerUrl}/v1/operations/${type}`, params, }); return data; } const api = { async getBlockCount() { const { data } = await (0, live_network_1.default)({ url: `${explorerUrl}/v1/blocks/count`, }); return data; }, async getLastBlock() { const { data } = await (0, live_network_1.default)({ 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) { const key = `${explorerUrl}|${address}`; const existing = accountByAddressInflight.get(key); if (existing) return existing; const request = (0, live_network_1.default)({ 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) { const { data } = await (0, live_network_1.default)({ url: `${explorerUrl}/v1/staking/unstake_requests`, params: { 'staker.eq': address, status: 'finalizable', 'select.values': 'actualAmount', limit: 1000, }, }); return data.reduce((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) { const { data } = await (0, live_network_1.default)({ 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, query) { // Remove undefined from query Object.entries(query).forEach(([key, value]) => value === undefined && delete query[key]); const { data } = await (0, live_network_1.default)({ url: url_1.default.format({ pathname: `${explorerUrl}/v1/accounts/${address}/operations`, query, }), }); return data; }, // https://api.tzkt.io/#operation/Blocks_GetByLevel async getBlockByLevel(level) { const { data } = await (0, live_network_1.default)({ 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) { if (levels.length === 0) return new Map(); const { data } = await (0, live_network_1.default)({ 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, cursor) { // "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 = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' }; if (cursor !== undefined) params['offset.cr'] = cursor; const { data } = await (0, live_network_1.default)({ 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, cursor, apiQueryParams = {}) { 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, cursor, apiQueryParams = {}) { 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, cursor) { // Same rationale as getBlockTransactionsPage: explicit ascending sort keeps the // offset.cr cursor advancing forward regardless of the API's default ordering. const params = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id', 'token.standard': 'fa2', }; if (cursor !== undefined) params['offset.cr'] = cursor; const { data } = await (0, live_network_1.default)({ 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 = {}) { const params = { ...clearUndefined(apiQueryParams), }; const { data } = await (0, live_network_1.default)({ 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, cursor) { const params = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' }; if (cursor !== undefined) params['offset.cr'] = cursor; const { data } = await (0, live_network_1.default)({ 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, cursor) { const params = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' }; if (cursor !== undefined) params['offset.cr'] = cursor; const { data } = await (0, live_network_1.default)({ 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, cursor) { const params = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' }; if (cursor !== undefined) params['offset.cr'] = cursor; const { data } = await (0, live_network_1.default)({ 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, cursor) { const params = { level, limit: BLOCK_PAGE_SIZE, 'sort.asc': 'id' }; if (cursor !== undefined) params['offset.cr'] = cursor; const { data } = await (0, live_network_1.default)({ 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, query) { const sortKey = query.sort === 'Descending' ? 'sort.desc' : 'sort.asc'; const params = { '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) => typeof id === 'number'); const originationIds = data .map((t) => t.originationId) .filter((id) => 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(transactions.map((t) => [t.id, t])); const originationsById = new Map(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, tokenFilter) { const params = { account: address, 'token.standard': 'fa2', }; if (tokenFilter) { params['token.contract'] = tokenFilter.contractAddress; params['token.tokenId'] = String(tokenFilter.tokenId); } const { data } = await (0, live_network_1.default)({ url: `${explorerUrl}/v1/tokens/balances`, params, }); return data; }, }; return api; } // TODO this has same purpose as api/listOperations const fetchAllTransactions = async (config, address, lastId) => { const api = createTzktApi(config); let ops = []; 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) { (0, logs_1.log)('tezos', 'id missing!'); return ops; } } while (--maxIteration); return ops; }; exports.fetchAllTransactions = fetchAllTransactions; /** * 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(config, pageFn, level, label) { const items = []; let cursor; 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) { (0, logs_1.log)('tezos', `${label}: maxTxQuery limit reached at level ${level}, result may be incomplete`); } return items; } const fetchBlockTransactions = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockTransactionsPage, level, 'fetchBlockTransactions'); exports.fetchBlockTransactions = fetchBlockTransactions; const fetchBlockTokenTransfers = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockTokenTransfersPage, level, 'fetchBlockTokenTransfers'); exports.fetchBlockTokenTransfers = fetchBlockTokenTransfers; const fetchBlockDelegations = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockDelegationsPage, level, 'fetchBlockDelegations'); exports.fetchBlockDelegations = fetchBlockDelegations; const fetchBlockStaking = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockStakingPage, level, 'fetchBlockStaking'); exports.fetchBlockStaking = fetchBlockStaking; const fetchBlockOriginations = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockOriginationsPage, level, 'fetchBlockOriginations'); exports.fetchBlockOriginations = fetchBlockOriginations; const fetchBlockReveals = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockRevealsPage, level, 'fetchBlockReveals'); exports.fetchBlockReveals = fetchBlockReveals; //# sourceMappingURL=tzkt.js.map