@ledgerhq/coin-tezos
Version:
434 lines • 19.4 kB
JavaScript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
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();
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.
*/
export 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 network({
url: `${explorerUrl}/v1/operations/${type}`,
params,
});
return data;
}
const api = {
async getBlockCount() {
const { data } = await network({
url: `${explorerUrl}/v1/blocks/count`,
});
return data;
},
async getLastBlock() {
const { data } = await network({
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 = network({
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 network({
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 network({
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 network({
url: URL.format({
pathname: `${explorerUrl}/v1/accounts/${address}/operations`,
query,
}),
});
return data;
},
// https://api.tzkt.io/#operation/Blocks_GetByLevel
async getBlockByLevel(level) {
const { data } = await network({
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 network({
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 network({
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 network({
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 network({
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 network({
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 network({
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 network({
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 network({
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 network({
url: `${explorerUrl}/v1/tokens/balances`,
params,
});
return data;
},
};
return api;
}
// TODO this has same purpose as api/listOperations
export 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) {
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(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) {
log('tezos', `${label}: maxTxQuery limit reached at level ${level}, result may be incomplete`);
}
return items;
}
export const fetchBlockTransactions = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockTransactionsPage, level, 'fetchBlockTransactions');
export const fetchBlockTokenTransfers = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockTokenTransfersPage, level, 'fetchBlockTokenTransfers');
export const fetchBlockDelegations = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockDelegationsPage, level, 'fetchBlockDelegations');
export const fetchBlockStaking = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockStakingPage, level, 'fetchBlockStaking');
export const fetchBlockOriginations = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockOriginationsPage, level, 'fetchBlockOriginations');
export const fetchBlockReveals = (config, level) => fetchBlockPaginated(config, createTzktApi(config).getBlockRevealsPage, level, 'fetchBlockReveals');
//# sourceMappingURL=tzkt.js.map