Skip to content

Commit

Permalink
fix(coin:tezos): api iterates over all results
Browse files Browse the repository at this point in the history
  • Loading branch information
jprudent committed Feb 6, 2025
1 parent ff98251 commit b9b6bc3
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 28 deletions.
4 changes: 2 additions & 2 deletions libs/coin-modules/coin-tezos/src/api/index.integ.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe("Tezos Api", () => {
expect(isSenderOrReceipt).toBeTruthy();
expect(operation.block).toBeDefined();
});
});
}, 15000);

it("returns all operations", async () => {
// When
Expand All @@ -67,7 +67,7 @@ describe("Tezos Api", () => {
// Find a way to create a unique id. In Tezos, the same hash may represent different operations in case of delegation.
const checkSet = new Set(tx.map(elt => `${elt.hash}${elt.type}${elt.senders[0]}`));
expect(checkSet.size).toEqual(tx.length);
});
}, 15000);
});

describe("lastBlock", () => {
Expand Down
41 changes: 41 additions & 0 deletions libs/coin-modules/coin-tezos/src/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createApi } from "./index";

const mockGetTransactions = jest.fn();
jest.mock("../logic", () => ({
listOperations: async () => {
return mockGetTransactions();
},
}));

const api = createApi({
baker: {
url: "https://baker.example.com",
},
explorer: {
url: "foo",
maxTxQuery: 1,
},
node: {
url: "bar",
},
fees: {
minGasLimit: 1,
minRevealGasLimit: 1,
minStorageLimit: 1,
minFees: 1,
minEstimatedFees: 2,
},
});

describe("get operations", () => {
it("operations", async () => {
mockGetTransactions.mockResolvedValue([[], ""]);

// When
const [operations, token] = await api.listOperations("addr", { minHeight: 100 });

// Then
expect(operations).toEqual([]);
expect(token).toEqual("");
});
});
67 changes: 64 additions & 3 deletions libs/coin-modules/coin-tezos/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
IncorrectTypeError,
Operation,
Pagination,
type Api,
type Transaction as ApiTransaction,
Expand All @@ -16,6 +17,7 @@ import {
rawEncode,
} from "../logic";
import api from "../network/tzkt";
import { assert } from "console";

export function createApi(config: TezosConfig): Api {
coinConfig.setCoinConfig(() => ({ ...config, status: { type: "active" } }));
Expand Down Expand Up @@ -65,7 +67,66 @@ async function estimate(addr: string, amount: bigint): Promise<bigint> {
return estimatedFees.estimatedFees;
}

function operations(address: string, _pagination: Pagination) {
//TODO implement properly with https://github.com/LedgerHQ/ledger-live/pull/8875
return listOperations(address, {});
type PaginationState = {
pageSize: number;
minHeight: number;
continueIterations: boolean;
nextCursor?: string;
accumulator: Operation[];
};

async function operationsFromHeight(
address: string,
start: number,
): Promise<[Operation[], string]> {
// FIXME add a hard limit to the number of iterations
async function fetchNextPage(state: PaginationState): Promise<PaginationState> {
// FIXME: check comment
// unfortunately, tzkt API does not support defining a minimum height for the operations
// to the strategy is to fetch operations from the top (newest first),
// iterating over pages until the minHeight limit is reached
// This strategy is not ideal for bootstraping large accounts with many operations,
// but it's the best one for the nominal case where
console.log("🐊🐊🐊🐊🐊", { ...state, accumulator: state.accumulator.length });
const [operations, newNextCursor] = await listOperations(address, {
limit: state.pageSize,
token: state.nextCursor,
sort: "Ascending",
});
const filteredOperations = operations.filter(op => op.block.height >= state.minHeight);
assert(
(operations.length == 0 && newNextCursor === "") ||
(operations.length > 0 && newNextCursor !== ""),
);
const isTruncated = operations.length !== filteredOperations.length;
const continueIteration = !(newNextCursor === "" || isTruncated);
const accumulated = filteredOperations.concat(state.accumulator);
return {
...state,
continueIterations: continueIteration,
nextCursor: newNextCursor,
accumulator: accumulated,
};
}

const firstState: PaginationState = {
pageSize: 100,
minHeight: start,
continueIterations: true,
nextCursor: "0", // this is supposed to be the lowest operation id
accumulator: [],
};

let state = await fetchNextPage(firstState);
while (state.continueIterations) {
state = await fetchNextPage(state);
}
return [state.accumulator, state.nextCursor || ""];
}

async function operations(
address: string,
{ minHeight }: Pagination,
): Promise<[Operation[], string]> {
return operationsFromHeight(address, minHeight);
}
18 changes: 14 additions & 4 deletions libs/coin-modules/coin-tezos/src/logic/listOperations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ jest.mock("../network", () => ({
},
}));

const options: { sort: "Ascending" | "Descending" } = { sort: "Ascending" };

describe("listOperations", () => {
afterEach(() => {
// mockGetServerInfos.mockClear();
Expand All @@ -20,7 +22,7 @@ describe("listOperations", () => {
// Given
mockNetworkGetTransactions.mockResolvedValue([]);
// When
const [results, token] = await listOperations("any address", {});
const [results, token] = await listOperations("any address", options);
// Then
expect(results).toEqual([]);
expect(token).toEqual("");
Expand Down Expand Up @@ -66,7 +68,7 @@ describe("listOperations", () => {
// Given
mockNetworkGetTransactions.mockResolvedValue([operation]);
// When
const [results, token] = await listOperations("any address", {});
const [results, token] = await listOperations("any address", options);
// Then
expect(results.length).toEqual(1);
expect(results[0].recipients).toEqual([someDestinationAddress]);
Expand All @@ -81,7 +83,7 @@ describe("listOperations", () => {
// Given
mockNetworkGetTransactions.mockResolvedValue([operation]);
// When
const [results, token] = await listOperations("any address", {});
const [results, token] = await listOperations("any address", options);
// Then
expect(results.length).toEqual(1);
expect(results[0].recipients).toEqual([]);
Expand All @@ -93,10 +95,18 @@ describe("listOperations", () => {
const operation = { ...undelegate, sender: null };
mockNetworkGetTransactions.mockResolvedValue([operation]);
// When
const [results, token] = await listOperations("any address", {});
const [results, token] = await listOperations("any address", options);
// Then
expect(results.length).toEqual(1);
expect(results[0].senders).toEqual([]);
expect(token).toEqual(JSON.stringify(operation.id));
});

it("should order the results in descending order even if the sort option is set to ascending", async () => {
const op1 = { ...undelegate, level: "1" };
const op2 = { ...undelegate, level: "2" };
mockNetworkGetTransactions.mockResolvedValue([op1, op2]);
const [results, _] = await listOperations("any address", { sort: "Ascending" });
expect(results.map(op => op.block.height)).toEqual(["2", "1"]);
});
});
32 changes: 24 additions & 8 deletions libs/coin-modules/coin-tezos/src/logic/listOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { log } from "@ledgerhq/logs";
import {
type APIDelegationType,
type APITransactionType,
AccountsGetOperationsOptions,
isAPIDelegationType,
isAPITransactionType,
} from "../network/types";
Expand All @@ -25,23 +26,38 @@ export type Operation = {
transactionSequenceNumber: number;
};

/**
* Returns list of "Transfer", "Delegate" and "Undelegate" Operations associated to an account.
* @param address Account address
* @param limit the maximum number of operations to return. Beware that's a weak limit, as explorers might not respect it.
* @param order whether to return operations starting from the top block or from the oldest block.
* "Descending" returns newest operation first, "Ascending" returns oldest operation first.
* It doesn't control the order of the operations in the result list:
* operations are always returned sorted in descending order (newest first).
* @param token a token to be used for pagination
* @returns a list of operations is descending (newest first) order and a token to be used for pagination
*/
export async function listOperations(
address: string,
{ token, limit }: { limit?: number; token?: string },
{ token, limit, sort }: { limit?: number; token?: string; sort: "Ascending" | "Descending" },
): Promise<[Operation[], string]> {
let options: { lastId?: number; limit?: number } = { limit: limit };
let options: AccountsGetOperationsOptions = { limit, sort };
if (token) {
options = { ...options, lastId: JSON.parse(token) };
}
const operations = await tzkt.getAccountOperations(address, options);
const lastOperation = operations.slice(-1)[0];
// it's important to get the last id from the **unfiltered** operation list
// otherwise we might miss operations
const nextToken = lastOperation ? JSON.stringify(lastOperation?.id) : "";
return [
operations
.filter(op => isAPITransactionType(op) || isAPIDelegationType(op))
.reduce((acc, op) => acc.concat(convertOperation(address, op)), [] as Operation[]),
nextToken,
];
const filteredOperations = operations
.filter(op => isAPITransactionType(op) || isAPIDelegationType(op))
.reduce((acc, op) => acc.concat(convertOperation(address, op)), [] as Operation[]);
if (sort === "Ascending") {
//results are always sorted in descending order
filteredOperations.reverse();
}
return [filteredOperations, nextToken];
}

// note that "initiator" of APITransactionType is never used in the conversion
Expand Down
8 changes: 8 additions & 0 deletions libs/coin-modules/coin-tezos/src/network/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export type APIDelegationType = CommonOperationType & {
export function isAPIDelegationType(op: APIOperation): op is APIDelegationType {
return op.type === "delegation";
}

// https://api.tzkt.io/#operation/Accounts_GetOperations
export type AccountsGetOperationsOptions = {
lastId?: number; // used as a pagination cursor to fetch more transactions
limit?: number;
sort?: "Descending" | "Ascending";
};

export type APIOperation =
| APITransactionType
| (CommonOperationType & {
Expand Down
17 changes: 6 additions & 11 deletions libs/coin-modules/coin-tezos/src/network/tzkt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import URL from "url";
import { log } from "@ledgerhq/logs";
import network from "@ledgerhq/live-network";
import coinConfig from "../config";
import { APIAccount, APIBlock, APIOperation } from "./types";
import { APIAccount, APIBlock, APIOperation, AccountsGetOperationsOptions as GetAccountOperationsOptions } from "./types";

const getExplorerUrl = () => coinConfig.getCoinConfig().explorer.url;

Expand Down Expand Up @@ -36,11 +36,7 @@ const api = {
// https://api.tzkt.io/#operation/Accounts_GetOperations
async getAccountOperations(
address: string,
query: {
lastId?: number;
sort?: number;
limit?: number;
},
query: GetAccountOperationsOptions,
): Promise<APIOperation[]> {
// Remove undefined from query
Object.entries(query).forEach(
Expand All @@ -56,10 +52,7 @@ const api = {
},
};

const sortOperation = {
ascending: 0,
descending: 1,
};

export const fetchAllTransactions = async (
address: string,
lastId?: number,
Expand All @@ -69,7 +62,9 @@ export const fetchAllTransactions = async (
do {
const newOps = await api.getAccountOperations(address, {
lastId,
sort: sortOperation.ascending,
// FIXME use object
// FIXME check if this is a number or string, documentation is unclear
sort: "Ascending",
});
if (newOps.length === 0) return ops;
ops = ops.concat(newOps);
Expand Down

0 comments on commit b9b6bc3

Please sign in to comment.