@ledgerhq/coin-tron
Version:
Ledger Tron Coin integration
110 lines (97 loc) • 4.39 kB
text/typescript
import { Operation, Page } from "@ledgerhq/coin-module-framework/api/index";
import { promiseAllBatched } from "@ledgerhq/live-promise";
import uniqBy from "lodash/uniqBy";
import { fetchTronAccountTxsPage, getBlock } from "../network";
import { fromTrongridTxInfoToOperation } from "../network/trongrid/trongrid-adapters";
import { Block } from "../network/types";
import {
compareTxsByTimestamp,
dropTxsAfterNextCursor,
dropTxsBeforeCursor,
parseCursor,
} from "./cursor";
import { TronEmptyPage } from "../types/errors";
// Pagination uses a SUI-style cursor approach to handle two separate endpoints
// (native transactions and TRC20 transactions) while maintaining chronological order.
//
// The cursor format is "{timestamp}:{txHash}" which identifies the last transaction
// returned. On the next page, we fetch from both endpoints starting at that timestamp,
// then filter out transactions at or before the cursor position.
//
// To ensure no transactions are skipped when endpoints have different page boundaries,
// we find the "boundary transaction" - the earliest (for asc) or latest (for desc) of
// the last transactions from each endpoint - and only return transactions up to that
// boundary. The next cursor points to this boundary transaction.
export type ListOperationsOptions = {
limit: number;
minTimestamp: number;
order: "asc" | "desc";
cursor?: string;
};
export async function listOperations(
address: string,
options: ListOperationsOptions,
): Promise<Page<Operation>> {
const { limit, order, cursor } = options;
const parsedCursor = parseCursor(cursor);
// For asc: cursor timestamp is the new lower bound (we're moving forward in time)
// For desc: cursor timestamp is the new upper bound (we're moving backward in time)
// minTimestamp remains the lower bound (the stopping point)
let fetchMinTimestamp: number;
let fetchMaxTimestamp: number | undefined;
if (order === "asc") {
fetchMinTimestamp = parsedCursor ? parsedCursor.timestamp : options.minTimestamp;
fetchMaxTimestamp = undefined;
} else {
fetchMinTimestamp = options.minTimestamp;
fetchMaxTimestamp = parsedCursor?.timestamp;
}
// Fetch native and TRC20 transactions in parallel from TronGrid.
// Both endpoints are queried with the same timestamp bounds to ensure
// we can properly merge and sort them chronologically.
const { nativeTxs, trc20Txs } = await fetchTronAccountTxsPage(address, {
limit,
minTimestamp: fetchMinTimestamp,
maxTimestamp: fetchMaxTimestamp,
order,
});
// TronGrid occasionally returns an empty page for a valid cursor (transient API failure).
// A cursor is only issued when TronGrid indicated hasNextPage=true, so 0 results here
// is never a legitimate end-of-stream — throw so the client can retry with the same cursor.
if (parsedCursor && nativeTxs.txs.length === 0 && trc20Txs.txs.length === 0) {
throw new TronEmptyPage(
`TronGrid returned empty page for cursor ${cursor} — transient failure, retry required`,
);
}
// Merge and dedupe: some transactions appear in both native and TRC20 results
const mergedTxs = uniqBy([...nativeTxs.txs, ...trc20Txs.txs], tx => tx.txID);
const sortedTxs = [...mergedTxs].sort(compareTxsByTimestamp(order));
const afterCursorTxs = dropTxsBeforeCursor({ txs: sortedTxs, order, cursor: parsedCursor });
const { txs: pageTxs, nextCursor } = dropTxsAfterNextCursor({
order,
cursor,
pageTxs: afterCursorTxs,
nativeResult: nativeTxs,
trc20Result: trc20Txs,
});
const blocksByHeight = new Map<number, Block>();
const uniqueHeights = Array.from(
new Set(pageTxs.map(tx => tx.blockHeight).filter((h): h is number => typeof h === "number")),
);
await promiseAllBatched(5, uniqueHeights, async height => {
const fetchedBlock = await getBlock(height);
blocksByHeight.set(height, fetchedBlock);
});
const operations = pageTxs.map(tx => {
const height = tx.blockHeight;
if (typeof height !== "number") {
throw new Error(`Transaction ${tx.txID} has no block height`);
}
const txBlock = blocksByHeight.get(height);
if (!txBlock) {
throw new Error(`Block ${height} not found for transaction ${tx.txID}`);
}
return fromTrongridTxInfoToOperation(tx, txBlock, address);
});
return { items: operations, next: nextCursor };
}