From 72ff094980fe9a99ecc0489267514452cc816f09 Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Tue, 12 Sep 2023 15:00:25 +0100 Subject: [PATCH 1/3] Add basic implementation of getCombinedOperations --- src/utils/redux/slices/assetsSlice.test.ts | 10 +++ src/utils/redux/slices/assetsSlice.ts | 7 +- src/utils/tezos/fetch.ts | 76 ++++++++++++++++++++-- src/utils/useAssetsPolling.ts | 7 ++ 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/utils/redux/slices/assetsSlice.test.ts b/src/utils/redux/slices/assetsSlice.test.ts index b50ed0ac59..a9869b6165 100644 --- a/src/utils/redux/slices/assetsSlice.test.ts +++ b/src/utils/redux/slices/assetsSlice.test.ts @@ -30,6 +30,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); }); @@ -51,6 +52,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); store.dispatch( @@ -76,6 +78,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); }); @@ -109,6 +112,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); }); @@ -136,6 +140,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); }); @@ -161,6 +166,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); }); @@ -194,6 +200,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); store.dispatch( @@ -229,6 +236,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); store.dispatch(networksActions.setCurrent(GHOSTNET)); }); @@ -263,6 +271,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); store.dispatch( @@ -298,6 +307,7 @@ describe("assetsSlice", () => { refetchTrigger: 0, lastTimeUpdated: null, isLoading: false, + latestOperations: [], }); }); }); diff --git a/src/utils/redux/slices/assetsSlice.ts b/src/utils/redux/slices/assetsSlice.ts index 43bd92cad7..5eef422d0f 100644 --- a/src/utils/redux/slices/assetsSlice.ts +++ b/src/utils/redux/slices/assetsSlice.ts @@ -3,7 +3,7 @@ import { DelegationOperation } from "@tzkt/sdk-api"; import { compact, groupBy, mapValues } from "lodash"; import accountsSlice from "./accountsSlice"; import { TezTransfer, TokenTransfer } from "../../../types/Transfer"; -import { TzktAccount } from "../../tezos"; +import { TzktAccount, TzktCombinedOperation } from "../../tezos"; import { fromRaw, RawTokenBalance, TokenBalance } from "../../../types/TokenBalance"; import { Delegate } from "../../../types/Delegate"; import { RawPkh } from "../../../types/Address"; @@ -18,6 +18,7 @@ type State = { tez: Record; tokens: Record; }; + latestOperations: TzktCombinedOperation[]; delegations: Record; bakers: Delegate[]; conversionRate: number | null; // XTZ/USD conversion rate @@ -50,6 +51,7 @@ const initialState: State = { }, transfers: { tez: {}, tokens: {} }, delegations: {}, + latestOperations: [], bakers: [], conversionRate: null, refetchTrigger: 0, @@ -136,6 +138,9 @@ const assetsSlice = createSlice({ setLastTimeUpdated: (state, { payload: lastTimeUpdated }: { payload: string }) => { state.lastTimeUpdated = lastTimeUpdated; }, + updateOperations: (state, { payload }: { payload: TzktCombinedOperation[] }) => { + state.latestOperations = payload; + }, }, }); diff --git a/src/utils/tezos/fetch.ts b/src/utils/tezos/fetch.ts index d9d586213d..3a42ddf7bc 100644 --- a/src/utils/tezos/fetch.ts +++ b/src/utils/tezos/fetch.ts @@ -7,6 +7,9 @@ import { tokensGetTokenTransfers, delegatesGet, TokenTransfer, + operationsGetOriginations, + TransactionOperation, + OriginationOperation, } from "@tzkt/sdk-api"; import axios from "axios"; import { coincapUrl } from "./consts"; @@ -16,11 +19,13 @@ import { RawTokenBalance } from "../../types/TokenBalance"; import { Network } from "../../types/Network"; import Semaphore from "@chriscdn/promise-semaphore"; import promiseRetry from "promise-retry"; +import { RawPkh } from "../../types/Address"; +import { sortBy } from "lodash"; // TzKT defines type Account = {type: string}; // whilst accountsGet returns all the info about accounts // for now we need only the balance, but we can extend it later -export type TzktAccount = { address: string; balance: number }; +export type TzktAccount = { address: RawPkh; balance: number }; const tzktRateLimiter = new Semaphore(10); @@ -49,7 +54,8 @@ export const getTokenBalances = async ( return response.data; }); -export const getTezTransfers = (address: string, network: Network): Promise => +// TODO: remove it when transition to combined operations is done +export const getTezTransfers = (address: RawPkh, network: Network): Promise => withRateLimit(() => operationsGetTransactions( { @@ -63,7 +69,69 @@ export const getTezTransfers = (address: string, network: Network): Promise => +export const getDelegations = async ( + addresses: RawPkh[], + network: Network +): Promise => + withRateLimit(() => + operationsGetDelegations( + { sender: { in: [addresses.join(",")] }, sort: { desc: "id" }, limit: 100 }, + { + baseUrl: network.tzktApiUrl, + } + ) + ); + +export const getTransactions = async ( + addresses: RawPkh[], + network: Network +): Promise => + withRateLimit(() => + operationsGetTransactions( + { + anyof: { fields: ["sender", "target"], in: [addresses.join(",")] }, + sort: { desc: "id" }, + limit: 100, + }, + { + baseUrl: network.tzktApiUrl, + } + ) + ); + +export const getOriginations = async ( + addresses: RawPkh[], + network: Network +): Promise => + withRateLimit(() => + operationsGetOriginations( + { sender: { in: [addresses.join(",")] }, sort: { desc: "id" }, limit: 100 }, + { + baseUrl: network.tzktApiUrl, + } + ) + ); + +export type TzktCombinedOperation = + | DelegationOperation + | TransactionOperation + | OriginationOperation; + +export const getCombinedOperations = async ( + addresses: RawPkh[], + network: Network +): Promise => { + const operations = await Promise.all([ + getTransactions(addresses, network), + getDelegations(addresses, network), + getOriginations(addresses, network), + ]); + return sortBy(operations.flat(), op => op.id) + .reverse() // TODO: add an option to sort asc too + .slice(0, 100); // TODO: make limit configurable to push the same number down to the API +}; + +export const getTokenTransfers = (address: RawPkh, network: Network): Promise => withRateLimit(() => tokensGetTokenTransfers( { @@ -78,7 +146,7 @@ export const getTokenTransfers = (address: string, network: Network): Promise => withRateLimit(() => diff --git a/src/utils/useAssetsPolling.ts b/src/utils/useAssetsPolling.ts index e55f945b26..08f901b28e 100644 --- a/src/utils/useAssetsPolling.ts +++ b/src/utils/useAssetsPolling.ts @@ -18,6 +18,7 @@ import { tokensActions } from "./redux/slices/tokensSlice"; import { getAccounts, getBakers, + getCombinedOperations, getLastDelegation, getLatestBlockLevel, getTezosPriceInUSD, @@ -103,6 +104,11 @@ const updateTokenTransfers = async (dispatch: AppDispatch, network: Network, pkh dispatch(assetsActions.updateTokenTransfers(tokenTransfers)); }; +const updateOperations = async (dispatch: AppDispatch, network: Network, pkhs: RawPkh[]) => { + const operations = await getCombinedOperations(pkhs, network); + dispatch(assetsActions.updateOperations(operations)); +}; + const updateAccountAssets = async ( dispatch: AppDispatch, network: Network, @@ -129,6 +135,7 @@ const updateAccountAssets = async ( updateTezTransfers(dispatch, network, allAccountAddresses), updateDelegations(dispatch, network, allAccountAddresses), updateTokenTransfers(dispatch, network, allAccountAddresses), + updateOperations(dispatch, network, allAccountAddresses), ]); dispatch(assetsActions.setLastTimeUpdated(new Date().toUTCString())); } finally { From 11125c10d9ff34ebbe6c5c786f06b6a4ca170294 Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Thu, 14 Sep 2023 12:56:09 +0100 Subject: [PATCH 2/3] Add useGetOperations hook --- src/utils/hooks/assetsHooks.ts | 4 ++ src/utils/tezos/fetch.ts | 55 ++++++++++++++++++----- src/views/operations/useGetOperations.tsx | 41 +++++++++++++++++ 3 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 src/views/operations/useGetOperations.tsx diff --git a/src/utils/hooks/assetsHooks.ts b/src/utils/hooks/assetsHooks.ts index 8f9d547b80..457e9ce9c7 100644 --- a/src/utils/hooks/assetsHooks.ts +++ b/src/utils/hooks/assetsHooks.ts @@ -193,3 +193,7 @@ export const useIsLoading = () => { export const useLastTimeUpdated = () => { return useAppSelector(state => state.assets.lastTimeUpdated); }; + +export const useGetLatestOperations = () => { + return useAppSelector(state => state.assets.latestOperations); +}; diff --git a/src/utils/tezos/fetch.ts b/src/utils/tezos/fetch.ts index 3a42ddf7bc..d12130af08 100644 --- a/src/utils/tezos/fetch.ts +++ b/src/utils/tezos/fetch.ts @@ -10,6 +10,8 @@ import { operationsGetOriginations, TransactionOperation, OriginationOperation, + OffsetParameter, + SortParameter, } from "@tzkt/sdk-api"; import axios from "axios"; import { coincapUrl } from "./consts"; @@ -71,11 +73,16 @@ export const getTezTransfers = (address: RawPkh, network: Network): Promise => withRateLimit(() => operationsGetDelegations( - { sender: { in: [addresses.join(",")] }, sort: { desc: "id" }, limit: 100 }, + { sender: { in: [addresses.join(",")] }, ...options }, { baseUrl: network.tzktApiUrl, } @@ -84,14 +91,18 @@ export const getDelegations = async ( export const getTransactions = async ( addresses: RawPkh[], - network: Network + network: Network, + options: { + offset?: OffsetParameter; + sort: SortParameter; + limit: number; + } ): Promise => withRateLimit(() => operationsGetTransactions( { anyof: { fields: ["sender", "target"], in: [addresses.join(",")] }, - sort: { desc: "id" }, - limit: 100, + ...options, }, { baseUrl: network.tzktApiUrl, @@ -101,11 +112,16 @@ export const getTransactions = async ( export const getOriginations = async ( addresses: RawPkh[], - network: Network + network: Network, + options: { + offset?: OffsetParameter; + sort: SortParameter; + limit: number; + } ): Promise => withRateLimit(() => operationsGetOriginations( - { sender: { in: [addresses.join(",")] }, sort: { desc: "id" }, limit: 100 }, + { sender: { in: [addresses.join(",")] }, ...options }, { baseUrl: network.tzktApiUrl, } @@ -119,16 +135,31 @@ export type TzktCombinedOperation = export const getCombinedOperations = async ( addresses: RawPkh[], - network: Network + network: Network, + options?: { + lastId?: number; + limit?: number; + sort?: "asc" | "desc"; + } ): Promise => { + const limit = options?.limit || 1; + const tzktRequestOptions = { + limit, + offset: options?.lastId ? { cr: options.lastId } : undefined, + sort: { [options?.sort ?? "desc"]: "id" }, + }; + const operations = await Promise.all([ - getTransactions(addresses, network), - getDelegations(addresses, network), - getOriginations(addresses, network), + getTransactions(addresses, network, tzktRequestOptions), + getDelegations(addresses, network, tzktRequestOptions), + getOriginations(addresses, network, tzktRequestOptions), ]); + + // ID is a shared sequence among all operations in TzKT + // so it's safe to use it for sorting & pagination return sortBy(operations.flat(), op => op.id) .reverse() // TODO: add an option to sort asc too - .slice(0, 100); // TODO: make limit configurable to push the same number down to the API + .slice(0, limit); }; export const getTokenTransfers = (address: RawPkh, network: Network): Promise => diff --git a/src/views/operations/useGetOperations.tsx b/src/views/operations/useGetOperations.tsx new file mode 100644 index 0000000000..1876e2e627 --- /dev/null +++ b/src/views/operations/useGetOperations.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { TzktCombinedOperation, getCombinedOperations } from "../../utils/tezos"; +import { useGetLatestOperations } from "../../utils/hooks/assetsHooks"; +import { uniqBy } from "lodash"; +import { useSelectedNetwork } from "../../utils/hooks/networkHooks"; +import { useAllAccounts } from "../../utils/hooks/accountHooks"; + +export const operationKey = (operation: TzktCombinedOperation): string => + `${operation.type}-${operation.id}`; + +// TODO: add support for filtering by account +// just offline filtering should be fine +// don't forget about sender/target +export const useGetOperations = () => { + const latestOperations = useGetLatestOperations(); + const network = useSelectedNetwork(); + const accounts = useAllAccounts(); + const [operations, setOperations] = useState(latestOperations); + const [hasMore, setHasMore] = useState(true); + + // when new operations are fetched, prepend them to the list + useEffect(() => { + // some of the operations may overlap, so we need to dedupe them + setOperations(currentOperations => + uniqBy([...latestOperations, ...currentOperations], operationKey) + ); + }, [latestOperations]); + + const loadMore = async () => { + const lastId = operations[operations.length - 1].id; + const nextChunk = await getCombinedOperations( + accounts.map(acc => acc.address.pkh), + network, + { lastId } + ); + setHasMore(nextChunk.length > 0); + setOperations(currentOperations => [...currentOperations, ...nextChunk]); + }; + + return { operations, loadMore, hasMore }; +}; From 7dc77cbcb97d6a9b7d58dddd036b6a653baf8b40 Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Fri, 15 Sep 2023 13:50:14 +0100 Subject: [PATCH 3/3] Add tests for getCombinedOperations --- src/utils/tezos/fetch.test.ts | 308 +++++++++++++++++++++++++++++++++- src/utils/tezos/fetch.ts | 12 +- 2 files changed, 309 insertions(+), 11 deletions(-) diff --git a/src/utils/tezos/fetch.test.ts b/src/utils/tezos/fetch.test.ts index b9535346e8..5ab223139b 100644 --- a/src/utils/tezos/fetch.test.ts +++ b/src/utils/tezos/fetch.test.ts @@ -1,26 +1,37 @@ import axios from "axios"; import { getAccounts, + getCombinedOperations, + getDelegations, getLastDelegation, + getOriginations, getTezosPriceInUSD, getTezTransfers, getTokenBalances, getTokenTransfers, + getTransactions, } from "./fetch"; -import { operationsGetTransactions, tokensGetTokenTransfers } from "@tzkt/sdk-api"; +import { + operationsGetDelegations, + operationsGetOriginations, + operationsGetTransactions, + tokensGetTokenTransfers, +} from "@tzkt/sdk-api"; import { coincapUrl } from "./consts"; import { mockContractAddress, mockImplicitAddress } from "../../mocks/factories"; import { hedgehoge, tzBtsc } from "../../mocks/fa12Tokens"; import { uUSD } from "../../mocks/fa2Tokens"; import { DefaultNetworks } from "../../types/Network"; +import { sortBy } from "lodash"; jest.mock("axios"); jest.mock("@tzkt/sdk-api", () => { return { - tokensGetTokenBalances: jest.fn(() => {}), - operationsGetTransactions: jest.fn(() => {}), - tokensGetTokenTransfers: jest.fn(() => {}), - operationsGetDelegations: () => Promise.resolve([{ type: "delegation" }]), + tokensGetTokenBalances: jest.fn(), + operationsGetTransactions: jest.fn(), + operationsGetDelegations: jest.fn(), + operationsGetOriginations: jest.fn(), + tokensGetTokenTransfers: jest.fn(), }; }); @@ -103,8 +114,13 @@ describe("tezos utils fetch", () => { }); test("getLastDelegation", async () => { + jest.mocked(operationsGetDelegations).mockResolvedValue([ + { id: 2, type: "delegation" }, + { id: 1, type: "delegation" }, + ]); const res = await getLastDelegation(mockImplicitAddress(0).pkh, network); - expect(res).toEqual({ type: "delegation" }); + + expect(res).toEqual({ id: 2, type: "delegation" }); }); test("getAccounts", async () => { @@ -129,5 +145,285 @@ describe("tezos utils fetch", () => { { address: mockImplicitAddress(1).pkh, balance: 123456 }, ]); }); + + test("getDelegations", async () => { + await getDelegations([mockImplicitAddress(0).pkh, mockImplicitAddress(1).pkh], network, { + sort: { desc: "id" }, + limit: 100, + offset: { cr: 123 }, + }); + + expect(jest.mocked(operationsGetDelegations)).toBeCalledWith( + { + offset: { cr: 123 }, + limit: 100, + sender: { + in: ["tz1gUNyn3hmnEWqkusWPzxRaon1cs7ndWh7h,tz1UZFB9kGauB6F5c2gfJo4hVcvrD8MeJ3Vf"], + }, + sort: { desc: "id" }, + }, + { baseUrl: network.tzktApiUrl } + ); + }); + + test("getOriginations", async () => { + await getOriginations([mockImplicitAddress(0).pkh, mockImplicitAddress(1).pkh], network, { + sort: { asc: "id" }, + limit: 100, + offset: { cr: 123 }, + }); + + expect(jest.mocked(operationsGetOriginations)).toBeCalledWith( + { + offset: { cr: 123 }, + limit: 100, + sender: { + in: ["tz1gUNyn3hmnEWqkusWPzxRaon1cs7ndWh7h,tz1UZFB9kGauB6F5c2gfJo4hVcvrD8MeJ3Vf"], + }, + sort: { asc: "id" }, + }, + { baseUrl: network.tzktApiUrl } + ); + }); + + test("getTransactions", async () => { + await getTransactions([mockImplicitAddress(0).pkh, mockImplicitAddress(1).pkh], network, { + sort: { asc: "id" }, + limit: 100, + offset: { cr: 123 }, + }); + + expect(jest.mocked(operationsGetTransactions)).toBeCalledWith( + { + offset: { cr: 123 }, + limit: 100, + anyof: { + fields: ["sender", "target"], + in: ["tz1gUNyn3hmnEWqkusWPzxRaon1cs7ndWh7h,tz1UZFB9kGauB6F5c2gfJo4hVcvrD8MeJ3Vf"], + }, + sort: { asc: "id" }, + }, + { baseUrl: network.tzktApiUrl } + ); + }); + + describe("getCombinedOperations", () => { + describe("request options", () => { + beforeEach(() => { + jest.mocked(operationsGetTransactions).mockResolvedValue([]); + jest.mocked(operationsGetDelegations).mockResolvedValue([]); + jest.mocked(operationsGetOriginations).mockResolvedValue([]); + }); + + describe("lastId", () => { + it("uses the provided value", async () => { + await getCombinedOperations([mockImplicitAddress(0).pkh], network, { lastId: 1234 }); + expect(jest.mocked(operationsGetTransactions)).toBeCalledWith( + expect.objectContaining({ offset: { cr: 1234 } }), + { baseUrl: network.tzktApiUrl } + ); + expect(jest.mocked(operationsGetDelegations)).toBeCalledWith( + expect.objectContaining({ offset: { cr: 1234 } }), + { + baseUrl: network.tzktApiUrl, + } + ); + expect(jest.mocked(operationsGetOriginations)).toBeCalledWith( + expect.objectContaining({ offset: { cr: 1234 } }), + { + baseUrl: network.tzktApiUrl, + } + ); + }); + + it("doesn't define the offset if none is provided", async () => { + await getCombinedOperations([mockImplicitAddress(0).pkh], network); + expect(jest.mocked(operationsGetTransactions)).toBeCalledWith( + expect.objectContaining({ offset: undefined }), + { baseUrl: network.tzktApiUrl } + ); + expect(jest.mocked(operationsGetDelegations)).toBeCalledWith( + expect.objectContaining({ offset: undefined }), + { + baseUrl: network.tzktApiUrl, + } + ); + expect(jest.mocked(operationsGetOriginations)).toBeCalledWith( + expect.objectContaining({ offset: undefined }), + { + baseUrl: network.tzktApiUrl, + } + ); + }); + }); + + describe("limit", () => { + it("uses the provided value", async () => { + await getCombinedOperations([mockImplicitAddress(0).pkh], network, { limit: 123 }); + expect(jest.mocked(operationsGetTransactions)).toBeCalledWith( + expect.objectContaining({ limit: 123 }), + { baseUrl: network.tzktApiUrl } + ); + expect(jest.mocked(operationsGetDelegations)).toBeCalledWith( + expect.objectContaining({ limit: 123 }), + { + baseUrl: network.tzktApiUrl, + } + ); + expect(jest.mocked(operationsGetOriginations)).toBeCalledWith( + expect.objectContaining({ limit: 123 }), + { + baseUrl: network.tzktApiUrl, + } + ); + }); + + it("defines a default limit if none is provided", async () => { + await getCombinedOperations([mockImplicitAddress(0).pkh], network); + expect(jest.mocked(operationsGetTransactions)).toBeCalledWith( + expect.objectContaining({ limit: 10 }), + { baseUrl: network.tzktApiUrl } + ); + expect(jest.mocked(operationsGetDelegations)).toBeCalledWith( + expect.objectContaining({ limit: 10 }), + { + baseUrl: network.tzktApiUrl, + } + ); + expect(jest.mocked(operationsGetOriginations)).toBeCalledWith( + expect.objectContaining({ limit: 10 }), + { + baseUrl: network.tzktApiUrl, + } + ); + }); + }); + + describe("sort", () => { + it("uses the provided value", async () => { + await getCombinedOperations([mockImplicitAddress(0).pkh], network, { sort: "asc" }); + expect(jest.mocked(operationsGetTransactions)).toBeCalledWith( + expect.objectContaining({ sort: { asc: "id" } }), + { baseUrl: network.tzktApiUrl } + ); + expect(jest.mocked(operationsGetDelegations)).toBeCalledWith( + expect.objectContaining({ sort: { asc: "id" } }), + { + baseUrl: network.tzktApiUrl, + } + ); + expect(jest.mocked(operationsGetOriginations)).toBeCalledWith( + expect.objectContaining({ sort: { asc: "id" } }), + { + baseUrl: network.tzktApiUrl, + } + ); + }); + + it("defines a default sort if none is provided", async () => { + await getCombinedOperations([mockImplicitAddress(0).pkh], network); + expect(jest.mocked(operationsGetTransactions)).toBeCalledWith( + expect.objectContaining({ sort: { desc: "id" } }), + { baseUrl: network.tzktApiUrl } + ); + expect(jest.mocked(operationsGetDelegations)).toBeCalledWith( + expect.objectContaining({ sort: { desc: "id" } }), + { + baseUrl: network.tzktApiUrl, + } + ); + expect(jest.mocked(operationsGetOriginations)).toBeCalledWith( + expect.objectContaining({ sort: { desc: "id" } }), + { + baseUrl: network.tzktApiUrl, + } + ); + }); + }); + }); + + it("always returns the provided limit at most", async () => { + const operations = []; + for (let i = 11; i > 0; i--) { + operations.push({ id: i }); + } + jest.mocked(operationsGetTransactions).mockResolvedValue([]); + jest.mocked(operationsGetDelegations).mockResolvedValue([]); + jest.mocked(operationsGetOriginations).mockResolvedValue(operations as any); + + const res = await getCombinedOperations([mockImplicitAddress(0).pkh], network, { + limit: 5, + }); + expect(res).toEqual(operations.slice(0, 5)); + + const res2 = await getCombinedOperations([mockImplicitAddress(0).pkh], network, { + limit: 11, + }); + expect(res2).toEqual(operations); + }); + + describe("responses alignment", () => { + test("the most recent records come from one source", async () => { + const delegations = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const originations = [{ id: 4 }, { id: 5 }, { id: 6 }]; + const transactions = [{ id: 7 }, { id: 8 }, { id: 9 }]; + + jest.mocked(operationsGetTransactions).mockResolvedValue(transactions as any); + jest.mocked(operationsGetDelegations).mockResolvedValue(delegations as any); + jest.mocked(operationsGetOriginations).mockResolvedValue(originations as any); + + const res = await getCombinedOperations([mockImplicitAddress(0).pkh], network, { + limit: 3, + }); + expect(res).toEqual(transactions.reverse()); + }); + + test("results are interleaved", async () => { + const delegations = [{ id: 1 }, { id: 21 }, { id: 51 }]; + const originations = [{ id: 2 }, { id: 4 }, { id: 55 }]; + const transactions = [{ id: 5 }, { id: 8 }, { id: 15 }]; + + jest.mocked(operationsGetTransactions).mockResolvedValue(transactions as any); + jest.mocked(operationsGetDelegations).mockResolvedValue(delegations as any); + jest.mocked(operationsGetOriginations).mockResolvedValue(originations as any); + + const res = await getCombinedOperations([mockImplicitAddress(0).pkh], network, { + limit: 5, + }); + expect(res).toEqual([{ id: 55 }, { id: 51 }, { id: 21 }, { id: 15 }, { id: 8 }]); + + const res2 = await getCombinedOperations([mockImplicitAddress(0).pkh], network, { + limit: 500, + }); + expect(res2).toEqual( + sortBy([...originations, ...delegations, ...transactions], o => -o.id) + ); + }); + + test("with ascending sort", async () => { + const delegations = [{ id: 1 }, { id: 21 }, { id: 51 }]; + const originations = [{ id: 2 }, { id: 4 }, { id: 55 }]; + const transactions = [{ id: 5 }, { id: 8 }, { id: 15 }]; + + jest.mocked(operationsGetTransactions).mockResolvedValue(transactions as any); + jest.mocked(operationsGetDelegations).mockResolvedValue(delegations as any); + jest.mocked(operationsGetOriginations).mockResolvedValue(originations as any); + + const res = await getCombinedOperations([mockImplicitAddress(0).pkh], network, { + limit: 5, + sort: "asc", + }); + expect(res).toEqual([{ id: 1 }, { id: 2 }, { id: 4 }, { id: 5 }, { id: 8 }]); + + const res2 = await getCombinedOperations([mockImplicitAddress(0).pkh], network, { + limit: 500, + sort: "asc", + }); + expect(res2).toEqual( + sortBy([...originations, ...delegations, ...transactions], o => o.id) + ); + }); + }); + }); }); }); diff --git a/src/utils/tezos/fetch.ts b/src/utils/tezos/fetch.ts index d12130af08..d6174778ce 100644 --- a/src/utils/tezos/fetch.ts +++ b/src/utils/tezos/fetch.ts @@ -142,11 +142,12 @@ export const getCombinedOperations = async ( sort?: "asc" | "desc"; } ): Promise => { - const limit = options?.limit || 1; + const limit = options?.limit || 10; + const sort = options?.sort ?? "desc"; const tzktRequestOptions = { limit, offset: options?.lastId ? { cr: options.lastId } : undefined, - sort: { [options?.sort ?? "desc"]: "id" }, + sort: { [sort]: "id" }, }; const operations = await Promise.all([ @@ -157,9 +158,10 @@ export const getCombinedOperations = async ( // ID is a shared sequence among all operations in TzKT // so it's safe to use it for sorting & pagination - return sortBy(operations.flat(), op => op.id) - .reverse() // TODO: add an option to sort asc too - .slice(0, limit); + return sortBy( + operations.flat(), + operation => (sort === "asc" ? operation.id : -(operation.id as number)) // operation#id is always defined + ).slice(0, limit); }; export const getTokenTransfers = (address: RawPkh, network: Network): Promise =>