Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: filters icp ledger transactions based on range if provided #5991

Merged
merged 5 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions frontend/src/lib/services/reporting.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { neuronStake } from "$lib/utils/neuron.utils";
import { SignIdentity } from "@dfinity/agent";
import type { TransactionWithId } from "@dfinity/ledger-icp";
import type { NeuronInfo } from "@dfinity/nns";
import { isNullish } from "@dfinity/utils";
import { isNullish, nonNullish } from "@dfinity/utils";

type TransactionEntity =
| {
Expand Down Expand Up @@ -94,18 +94,25 @@ export const getAccountTransactionsConcurrently = async ({
return entitiesAndTransactions;
};

type DateRange = {
yhabib marked this conversation as resolved.
Show resolved Hide resolved
from?: bigint;
to?: bigint;
};

export const getAllTransactionsFromAccountAndIdentity = async ({
accountId,
identity,
lastTransactionId = undefined,
allTransactions = [],
currentPageIndex = 1,
range,
}: {
accountId: string;
identity: SignIdentity;
lastTransactionId?: bigint;
allTransactions?: TransactionWithId[];
currentPageIndex?: number;
range?: DateRange;
}): Promise<TransactionWithId[] | undefined> => {
// Based on
// https://github.com/dfinity/ic/blob/master/rs/ledger_suite/icp/index/src/lib.rs#L31
Expand All @@ -121,7 +128,7 @@ export const getAllTransactionsFromAccountAndIdentity = async ({
console.warn(
`Reached maximum limit of iterations(${maxNumberOfPages}). Stopping.`
);
return allTransactions;
return filterTransactionsByRange(allTransactions, range);
}

const { transactions, oldestTxId } = await getTransactions({
Expand All @@ -133,6 +140,17 @@ export const getAllTransactionsFromAccountAndIdentity = async ({

const updatedTransactions = [...allTransactions, ...transactions];

// Early return if we've gone past our date range. It assumes sorted transactions from newest to oldest.
const oldestTransactionInPageTimestamp = getTimestampFromTransaction(
transactions[transactions.length - 1]
);
const from = range?.from;
if (nonNullish(from) && nonNullish(oldestTransactionInPageTimestamp)) {
if (oldestTransactionInPageTimestamp < from) {
return filterTransactionsByRange(updatedTransactions, range);
}
}

// We consider it complete if we find the oldestTxId in the list of transactions or if oldestTxId is null.
// The latter condition is necessary if the list of transactions is empty, which would otherwise return false.
const completed =
Expand All @@ -146,12 +164,41 @@ export const getAllTransactionsFromAccountAndIdentity = async ({
lastTransactionId: lastTx.id,
allTransactions: updatedTransactions,
currentPageIndex: currentPageIndex + 1,
range,
});
}

return updatedTransactions;
return filterTransactionsByRange(updatedTransactions, range);
} catch (error) {
console.error("Error loading ICP account transactions:", error);
return allTransactions;
return filterTransactionsByRange(allTransactions, range);
yhabib marked this conversation as resolved.
Show resolved Hide resolved
}
};

// Helper function to filter transactions by date range
const filterTransactionsByRange = (
transactions: TransactionWithId[],
range?: DateRange
): TransactionWithId[] => {
if (isNullish(range)) return transactions;
return transactions.filter((tx) => {
const timestamp = getTimestampFromTransaction(tx);
if (isNullish(timestamp)) return false;

const from = range.from;
if (nonNullish(from) && timestamp < from) {
return false;
}

const to = range.to;
if (nonNullish(to) && timestamp > to) {
return false;
}

return true;
});
};

const getTimestampFromTransaction = (tx: TransactionWithId): bigint | null => {
return tx.transaction.created_at_time?.[0]?.timestamp_nanos || null;
};
242 changes: 239 additions & 3 deletions frontend/src/tests/lib/services/reporting.services.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
mockMainAccount,
mockSubAccount,
} from "$tests/mocks/icp-accounts.store.mock";
import { createTransactionWithId } from "$tests/mocks/icp-transactions.mock";
import {
createTransactionWithId,
dateToNanoSeconds,
} from "$tests/mocks/icp-transactions.mock";
import { mockNeuron } from "$tests/mocks/neurons.mock";
import type { SignIdentity } from "@dfinity/agent";

Expand Down Expand Up @@ -114,14 +117,14 @@ describe("reporting service", () => {

it("should handle errors and return accumulated transactions", async () => {
const firstBatch = [
createTransactionWithId({ id: 1n }),
createTransactionWithId({ id: 3n }),
createTransactionWithId({ id: 2n }),
];

spyGetTransactions
.mockResolvedValueOnce({
transactions: firstBatch,
oldestTxId: 2000n,
oldestTxId: 1n,
})
.mockRejectedValueOnce(new Error("API Error"));

Expand All @@ -133,6 +136,239 @@ describe("reporting service", () => {
expect(result).toEqual(firstBatch);
expect(spyGetTransactions).toHaveBeenCalledTimes(2);
});

it('should filter "to" the provided date', async () => {
const allTransactions = [
createTransactionWithId({
id: 3n,
timestamp: new Date("2023-01-02T00:00:00.000Z"),
}),
createTransactionWithId({
id: 2n,
timestamp: new Date("2023-01-01T00:00:00.000Z"),
}),
createTransactionWithId({
id: 1n,
timestamp: new Date("2022-12-31T00:00:00.000Z"),
}),
];

spyGetTransactions.mockResolvedValue({
transactions: allTransactions,
oldestTxId: 1n,
});

const result = await getAllTransactionsFromAccountAndIdentity({
accountId: mockAccountId,
identity: mockSignInIdentity,
range: {
to: dateToNanoSeconds(new Date("2023-01-01T00:00:00.000Z")),
},
});

expect(result).toHaveLength(2);
expect(result).toEqual(allTransactions.slice(1));
expect(spyGetTransactions).toHaveBeenCalledTimes(1);
});

it('should filter "from" the provided date', async () => {
const allTransactions = [
createTransactionWithId({
id: 3n,
timestamp: new Date("2023-01-02T00:00:00.000Z"),
}),
createTransactionWithId({
id: 2n,
timestamp: new Date("2023-01-01T00:00:00.000Z"),
}),
createTransactionWithId({
id: 1n,
timestamp: new Date("2022-12-31T00:00:00.000Z"),
}),
];

spyGetTransactions.mockResolvedValue({
transactions: allTransactions,
oldestTxId: 1n,
});

const result = await getAllTransactionsFromAccountAndIdentity({
accountId: mockAccountId,
identity: mockSignInIdentity,
range: {
from: dateToNanoSeconds(new Date("2023-01-01T00:00:00.000Z")),
},
});

expect(result).toHaveLength(2);
expect(result).toEqual(allTransactions.slice(0, 2));
expect(spyGetTransactions).toHaveBeenCalledTimes(1);
});

it("should handle date range where no transactions match", async () => {
const allTransactions = [
createTransactionWithId({
id: 3n,
timestamp: new Date("2023-01-02T00:00:00.000Z"),
}),
createTransactionWithId({
id: 2n,
timestamp: new Date("2022-12-30T00:00:00.000Z"),
}),
createTransactionWithId({
id: 1n,
timestamp: new Date("2022-12-29T00:00:00.000Z"),
}),
];

spyGetTransactions.mockResolvedValue({
transactions: allTransactions,
oldestTxId: 1n,
});

const result = await getAllTransactionsFromAccountAndIdentity({
accountId: mockAccountId,
identity: mockSignInIdentity,
range: {
to: dateToNanoSeconds(new Date("2023-01-01T00:00:00.000Z")),
from: dateToNanoSeconds(new Date("2022-12-31T00:00:00.000Z")),
},
});

expect(result).toHaveLength(0);
expect(spyGetTransactions).toHaveBeenCalledTimes(1);
});

it('should return early if the last transaction is in the current page is older than "from" date', async () => {
const allTransactions = [
createTransactionWithId({
id: 3n,
timestamp: new Date("2023-01-02T00:00:00.000Z"),
}),
createTransactionWithId({
id: 2n,
timestamp: new Date("2022-12-31T00:00:00.000Z"),
}),
createTransactionWithId({
id: 1n,
timestamp: new Date("2022-12-30T00:00:00.000Z"),
}),
];
const firstBatchOfMockTransactions = allTransactions.slice(0, 2);
const secondBatchOfMockTransactions = allTransactions.slice(2);

spyGetTransactions
.mockResolvedValueOnce({
transactions: firstBatchOfMockTransactions,
oldestTxId: 1n,
})
.mockResolvedValueOnce({
transactions: secondBatchOfMockTransactions,
oldestTxId: 1n,
});

const result = await getAllTransactionsFromAccountAndIdentity({
accountId: mockAccountId,
identity: mockSignInIdentity,
range: {
from: dateToNanoSeconds(new Date("2023-01-01T00:00:00.000Z")),
},
});

expect(result).toHaveLength(1);
expect(result).toEqual(allTransactions.slice(0, 1));
expect(spyGetTransactions).toHaveBeenCalledTimes(1);
});

it('should handle a range with both "from" and "to" dates', async () => {
const allTransactions = [
createTransactionWithId({
id: 6n,
timestamp: new Date("2023-02-02T00:00:00.000Z"),
}),
createTransactionWithId({
id: 5n,
timestamp: new Date("2023-01-01T00:00:00.000Z"),
}),
createTransactionWithId({
id: 4n,
timestamp: new Date("2022-12-31T10:00:00.000Z"),
}),
createTransactionWithId({
id: 3n,
timestamp: new Date("2022-12-31T00:00:00.000Z"),
}),
createTransactionWithId({
id: 2n,
timestamp: new Date("2022-12-30T00:00:00.000Z"),
}),
createTransactionWithId({
id: 1n,
timestamp: new Date("2022-11-20T00:00:00.000Z"),
}),
];
const firstBatchOfMockTransactions = allTransactions.slice(0, 3);
const secondBatchOfMockTransactions = allTransactions.slice(3, 6);

spyGetTransactions
.mockResolvedValueOnce({
transactions: firstBatchOfMockTransactions,
oldestTxId: 1n,
})
.mockResolvedValueOnce({
transactions: secondBatchOfMockTransactions,
oldestTxId: 1n,
});

const result = await getAllTransactionsFromAccountAndIdentity({
accountId: mockAccountId,
identity: mockSignInIdentity,
range: {
to: dateToNanoSeconds(new Date("2023-01-02T00:00:00.000Z")),
from: dateToNanoSeconds(new Date("2022-11-30T00:00:00.000Z")),
},
});
expect(result).toHaveLength(4);
expect(result).toEqual(allTransactions.slice(1, -1));
expect(spyGetTransactions).toHaveBeenCalledTimes(2);
});

it("should filter the transactions even if one call fails", async () => {
const firstBatchOfMockTransactions = [
createTransactionWithId({
id: 6n,
timestamp: new Date("2023-02-02T00:00:00.000Z"),
}),
createTransactionWithId({
id: 5n,
timestamp: new Date("2023-01-01T00:00:00.000Z"),
}),
createTransactionWithId({
id: 4n,
timestamp: new Date("2022-12-31T10:00:00.000Z"),
}),
];
spyGetTransactions
.mockResolvedValueOnce({
transactions: firstBatchOfMockTransactions,
oldestTxId: 3n,
})
.mockRejectedValueOnce(new Error("API Error"));
const result = await getAllTransactionsFromAccountAndIdentity({
accountId: mockAccountId,
identity: mockSignInIdentity,
range: {
to: dateToNanoSeconds(new Date("2023-01-02T00:00:00.000Z")),
from: dateToNanoSeconds(new Date("2022-11-30T00:00:00.000Z")),
},
});
expect(result).toHaveLength(2);
expect(result).toEqual([
firstBatchOfMockTransactions[1],
firstBatchOfMockTransactions[2],
]);
expect(spyGetTransactions).toHaveBeenCalledTimes(2);
});
});

describe("getAccountTransactionsConcurrently", () => {
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/tests/mocks/icp-transactions.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export const mockTransactionTransfer: Transaction = {
timestamp: [{ timestamp_nanos: 235n }],
};

export const dateToNanoSeconds = (date: Date): bigint => {
return BigInt(date.getTime()) * BigInt(NANO_SECONDS_IN_MILLISECOND);
};

const defaultTimestamp = new Date("2023-01-01T00:00:00.000Z");
export const createTransactionWithId = ({
memo,
Expand All @@ -35,8 +39,7 @@ export const createTransactionWithId = ({
id?: bigint;
}): TransactionWithId => {
const timestampNanos = {
timestamp_nanos:
BigInt(timestamp.getTime()) * BigInt(NANO_SECONDS_IN_MILLISECOND),
timestamp_nanos: dateToNanoSeconds(timestamp),
};
return {
id,
Expand Down
Loading