Skip to content

Commit 591dc1b

Browse files
committed
fix(coin:tezos): api iterates over all results
1 parent eb1fc01 commit 591dc1b

File tree

8 files changed

+210
-29
lines changed

8 files changed

+210
-29
lines changed

.vscode/launch.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@
1616
"type": "node",
1717
"request": "attach",
1818
"skipFiles": ["<node_internals>/**"]
19+
},
20+
{
21+
"type": "node",
22+
"request": "launch",
23+
"name": "Debug Jest Tests",
24+
"program": "${workspaceFolder}/node_modules/.bin/jest",
25+
"args": ["--runInBand", "coin-tezoz", "api/index.test.ts"],
26+
"console": "integratedTerminal",
27+
"internalConsoleOptions": "neverOpen",
28+
"skipFiles": ["<node_internals>/**"],
29+
"runtimeArgs": ["--inspect-brk"],
30+
"sourceMaps": true,
31+
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
1932
}
2033
]
2134
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Operation } from "@ledgerhq/coin-framework/lib/api/types";
2+
import { createApi } from "./index";
3+
4+
const logicGetTransactions = jest.fn();
5+
jest.mock("../logic", () => ({
6+
listOperations: async () => {
7+
return logicGetTransactions();
8+
},
9+
}));
10+
11+
const api = createApi({
12+
baker: {
13+
url: "https://baker.example.com",
14+
},
15+
explorer: {
16+
url: "foo",
17+
maxTxQuery: 1,
18+
},
19+
node: {
20+
url: "bar",
21+
},
22+
fees: {
23+
minGasLimit: 1,
24+
minRevealGasLimit: 1,
25+
minStorageLimit: 1,
26+
minFees: 1,
27+
minEstimatedFees: 2,
28+
},
29+
});
30+
31+
describe("get operations", () => {
32+
afterEach(() => {
33+
logicGetTransactions.mockClear();
34+
});
35+
36+
it("operations", async () => {
37+
logicGetTransactions.mockResolvedValue([[], ""]);
38+
39+
// When
40+
const [operations, token] = await api.listOperations("addr", { minHeight: 100 });
41+
42+
// Then
43+
expect(operations).toEqual([]);
44+
expect(token).toEqual("");
45+
});
46+
47+
const op: Operation = {
48+
hash: "opHash",
49+
address: "tz1...",
50+
type: "transaction",
51+
value: BigInt(1000),
52+
fee: BigInt(100),
53+
block: {
54+
hash: "blockHash",
55+
height: 123456,
56+
time: new Date(),
57+
},
58+
senders: ["tz1Sender"],
59+
recipients: ["tz1Recipient"],
60+
date: new Date(),
61+
transactionSequenceNumber: 1,
62+
};
63+
64+
it("stops iterating after 10 iterations", async () => {
65+
logicGetTransactions.mockResolvedValue([[op], "888"]);
66+
const [operations, token] = await api.listOperations("addr", { minHeight: 100 });
67+
expect(logicGetTransactions).toHaveBeenCalledTimes(10);
68+
expect(operations.length).toBe(10);
69+
expect(token).toEqual("888");
70+
});
71+
});

libs/coin-modules/coin-tezos/src/api/index.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
IncorrectTypeError,
3+
Operation,
34
Pagination,
45
type Api,
56
type Transaction as ApiTransaction,
@@ -16,6 +17,8 @@ import {
1617
rawEncode,
1718
} from "../logic";
1819
import api from "../network/tzkt";
20+
import { assert } from "console";
21+
import { log } from "@ledgerhq/logs";
1922

2023
export function createApi(config: TezosConfig): Api {
2124
coinConfig.setCoinConfig(() => ({ ...config, status: { type: "active" } }));
@@ -65,7 +68,62 @@ async function estimate(addr: string, amount: bigint): Promise<bigint> {
6568
return estimatedFees.estimatedFees;
6669
}
6770

68-
function operations(address: string, _pagination: Pagination) {
69-
//TODO implement properly with https://github.com/LedgerHQ/ledger-live/pull/8875
70-
return listOperations(address, {});
71+
type PaginationState = {
72+
readonly pageSize: number;
73+
readonly maxIterations: number; // a security to avoid infinite loop
74+
currentIteration: number;
75+
readonly minHeight: number;
76+
continueIterations: boolean;
77+
nextCursor?: string;
78+
accumulator: Operation[];
79+
};
80+
81+
async function fetchNextPage(address: string, state: PaginationState): Promise<PaginationState> {
82+
const [operations, newNextCursor] = await listOperations(address, {
83+
limit: state.pageSize,
84+
token: state.nextCursor,
85+
sort: "Ascending",
86+
minHeight: state.minHeight,
87+
});
88+
const newCurrentIteration = state.currentIteration + 1;
89+
let continueIteration = newNextCursor !== "";
90+
if (newCurrentIteration >= state.maxIterations) {
91+
log("coin:tezos", "(api/operations): max iterations reached", state.maxIterations);
92+
continueIteration = false;
93+
}
94+
const accumulated = operations.concat(state.accumulator);
95+
return {
96+
...state,
97+
continueIterations: continueIteration,
98+
currentIteration: newCurrentIteration,
99+
nextCursor: newNextCursor,
100+
accumulator: accumulated,
101+
};
102+
}
103+
104+
async function operationsFromHeight(
105+
address: string,
106+
start: number,
107+
): Promise<[Operation[], string]> {
108+
const firstState: PaginationState = {
109+
pageSize: 200,
110+
maxIterations: 10,
111+
currentIteration: 0,
112+
minHeight: start,
113+
continueIterations: true,
114+
accumulator: [],
115+
};
116+
117+
let state = await fetchNextPage(address, firstState);
118+
while (state.continueIterations) {
119+
state = await fetchNextPage(address, state);
120+
}
121+
return [state.accumulator, state.nextCursor || ""];
122+
}
123+
124+
async function operations(
125+
address: string,
126+
{ minHeight }: Pagination,
127+
): Promise<[Operation[], string]> {
128+
return operationsFromHeight(address, minHeight);
71129
}

libs/coin-modules/coin-tezos/src/logic/listOperations.test.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ jest.mock("../network", () => ({
1010
},
1111
}));
1212

13+
const options: { sort: "Ascending" | "Descending"; minHeight: number } = {
14+
sort: "Ascending",
15+
minHeight: 0,
16+
};
17+
1318
describe("listOperations", () => {
1419
afterEach(() => {
1520
mockNetworkGetTransactions.mockClear();
@@ -19,7 +24,7 @@ describe("listOperations", () => {
1924
// Given
2025
mockNetworkGetTransactions.mockResolvedValue([]);
2126
// When
22-
const [results, token] = await listOperations("any address", {});
27+
const [results, token] = await listOperations("any address", options);
2328
// Then
2429
expect(results).toEqual([]);
2530
expect(token).toEqual("");
@@ -65,7 +70,7 @@ describe("listOperations", () => {
6570
// Given
6671
mockNetworkGetTransactions.mockResolvedValue([operation]);
6772
// When
68-
const [results, token] = await listOperations("any address", {});
73+
const [results, token] = await listOperations("any address", options);
6974
// Then
7075
expect(results.length).toEqual(1);
7176
expect(results[0].recipients).toEqual([someDestinationAddress]);
@@ -80,7 +85,7 @@ describe("listOperations", () => {
8085
// Given
8186
mockNetworkGetTransactions.mockResolvedValue([operation]);
8287
// When
83-
const [results, token] = await listOperations("any address", {});
88+
const [results, token] = await listOperations("any address", options);
8489
// Then
8590
expect(results.length).toEqual(1);
8691
expect(results[0].recipients).toEqual([]);
@@ -92,10 +97,18 @@ describe("listOperations", () => {
9297
const operation = { ...undelegate, sender: null };
9398
mockNetworkGetTransactions.mockResolvedValue([operation]);
9499
// When
95-
const [results, token] = await listOperations("any address", {});
100+
const [results, token] = await listOperations("any address", options);
96101
// Then
97102
expect(results.length).toEqual(1);
98103
expect(results[0].senders).toEqual([]);
99104
expect(token).toEqual(JSON.stringify(operation.id));
100105
});
106+
107+
it("should order the results in descending order even if the sort option is set to ascending", async () => {
108+
const op1 = { ...undelegate, level: "1" };
109+
const op2 = { ...undelegate, level: "2" };
110+
mockNetworkGetTransactions.mockResolvedValue([op1, op2]);
111+
const [results, _] = await listOperations("any address", options);
112+
expect(results.map(op => op.block.height)).toEqual(["2", "1"]);
113+
});
101114
});

libs/coin-modules/coin-tezos/src/logic/listOperations.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { log } from "@ledgerhq/logs";
33
import {
44
type APIDelegationType,
55
type APITransactionType,
6+
AccountsGetOperationsOptions,
67
isAPIDelegationType,
78
isAPITransactionType,
89
} from "../network/types";
@@ -24,23 +25,44 @@ export type Operation = {
2425
transactionSequenceNumber: number;
2526
};
2627

28+
/**
29+
* Returns list of "Transfer", "Delegate" and "Undelegate" Operations associated to an account.
30+
* @param address Account address
31+
* @param limit the maximum number of operations to return. Beware that's a weak limit, as explorers might not respect it.
32+
* @param order whether to return operations starting from the top block or from the oldest block.
33+
* "Descending" returns newest operation first, "Ascending" returns oldest operation first.
34+
* It doesn't control the order of the operations in the result list:
35+
* operations are always returned sorted in descending order (newest first).
36+
* @param minHeight retrieve operations from a specific block height until top most (inclusive).
37+
* @param token a token to be used for pagination
38+
* @returns a list of operations is descending (newest first) order and a token to be used for pagination
39+
*/
2740
export async function listOperations(
2841
address: string,
29-
{ token, limit }: { limit?: number; token?: string },
42+
{
43+
token,
44+
limit,
45+
sort,
46+
minHeight,
47+
}: { limit?: number; token?: string; sort: "Ascending" | "Descending"; minHeight: number },
3048
): Promise<[Operation[], string]> {
31-
let options: { lastId?: number; limit?: number } = { limit: limit };
49+
let options: AccountsGetOperationsOptions = { limit, sort, "level.ge": minHeight };
3250
if (token) {
3351
options = { ...options, lastId: JSON.parse(token) };
3452
}
3553
const operations = await tzkt.getAccountOperations(address, options);
3654
const lastOperation = operations.slice(-1)[0];
55+
// it's important to get the last id from the **unfiltered** operation list
56+
// otherwise we might miss operations
3757
const nextToken = lastOperation ? JSON.stringify(lastOperation?.id) : "";
38-
return [
39-
operations
40-
.filter(op => isAPITransactionType(op) || isAPIDelegationType(op))
41-
.reduce((acc, op) => acc.concat(convertOperation(address, op)), [] as Operation[]),
42-
nextToken,
43-
];
58+
const filteredOperations = operations
59+
.filter(op => isAPITransactionType(op) || isAPIDelegationType(op))
60+
.reduce((acc, op) => acc.concat(convertOperation(address, op)), [] as Operation[]);
61+
if (sort === "Ascending") {
62+
//results are always sorted in descending order
63+
filteredOperations.reverse();
64+
}
65+
return [filteredOperations, nextToken];
4466
}
4567

4668
// note that "initiator" of APITransactionType is never used in the conversion

libs/coin-modules/coin-tezos/src/network/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ export type APIDelegationType = CommonOperationType & {
6161
export function isAPIDelegationType(op: APIOperation): op is APIDelegationType {
6262
return op.type === "delegation";
6363
}
64+
65+
// https://api.tzkt.io/#operation/Accounts_GetOperations
66+
export type AccountsGetOperationsOptions = {
67+
lastId?: number; // used as a pagination cursor to fetch more transactions
68+
limit?: number;
69+
sort?: "Descending" | "Ascending";
70+
// the minimum height of the block the operation is in
71+
"level.ge": number;
72+
};
73+
6474
export type APIOperation =
6575
| APITransactionType
6676
| (CommonOperationType & {

libs/coin-modules/coin-tezos/src/network/tzkt.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import URL from "url";
22
import { log } from "@ledgerhq/logs";
33
import network from "@ledgerhq/live-network";
44
import coinConfig from "../config";
5-
import { APIAccount, APIBlock, APIOperation } from "./types";
5+
import { APIAccount, APIBlock, APIOperation, AccountsGetOperationsOptions } from "./types";
66

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

@@ -36,11 +36,7 @@ const api = {
3636
// https://api.tzkt.io/#operation/Accounts_GetOperations
3737
async getAccountOperations(
3838
address: string,
39-
query: {
40-
lastId?: number;
41-
sort?: number;
42-
limit?: number;
43-
},
39+
query: AccountsGetOperationsOptions,
4440
): Promise<APIOperation[]> {
4541
// Remove undefined from query
4642
Object.entries(query).forEach(
@@ -56,10 +52,7 @@ const api = {
5652
},
5753
};
5854

59-
const sortOperation = {
60-
ascending: 0,
61-
descending: 1,
62-
};
55+
// TODO this has same purpose as api/listOperations
6356
export const fetchAllTransactions = async (
6457
address: string,
6558
lastId?: number,
@@ -69,7 +62,8 @@ export const fetchAllTransactions = async (
6962
do {
7063
const newOps = await api.getAccountOperations(address, {
7164
lastId,
72-
sort: sortOperation.ascending,
65+
sort: "Ascending",
66+
"level.ge": 0,
7367
});
7468
if (newOps.length === 0) return ops;
7569
ops = ops.concat(newOps);

libs/coin-modules/coin-xrp/src/api/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ async function estimate(_addr: string, _amount: bigint): Promise<bigint> {
4444
}
4545

4646
type PaginationState = {
47-
pageSize: number; // must be large enough to avoid unnecessary calls to the underlying explorer
48-
maxIterations: number; // a security to avoid infinite loop
47+
readonly pageSize: number; // must be large enough to avoid unnecessary calls to the underlying explorer
48+
readonly maxIterations: number; // a security to avoid infinite loop
4949
currentIteration: number;
50-
minHeight: number;
50+
readonly minHeight: number;
5151
continueIterations: boolean;
5252
apiNextCursor?: string;
5353
accumulator: XrpOperation[];

0 commit comments

Comments
 (0)