@ledgerhq/coin-tezos
Version:
561 lines (515 loc) • 20.4 kB
text/typescript
// 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')