Skip to content

Commit

Permalink
feat: filters icp ledger transactions based on range if provided (#5991)
Browse files Browse the repository at this point in the history
# Motivation

Users can select a date range before generating a report. We need to
filter the results in the front end and stop fetching pages if we exceed
the `from` date.

The ledger canister returns transactions sorted by date, with the newest
transaction listed first and the oldest transaction listed last.

```
Present                                                                     Past
(now)                                                                      (t)
0-----------------------[TO]-----------------[FROM]--------------------------->t
|                        |                     |                            |
|                        |                     |                            |
Current time        Newer limit            Older limit                   Earliest
                                                                        transaction

- Transactions BETWEEN 'from' and 'to' are included
- Transactions NEWER than 'from' are excluded
- Transactions OLDER than 'to' are excluded

```

# Changes

- Extends `getAllTransactionsFromAccountAndIdentity` to accept a date
range for filtering results and to stop fetching.

# Tests

- Unit tests

# Todos

- [ ] Add entry to changelog (if necessary).
Not necessary
  • Loading branch information
yhabib authored Dec 16, 2024
1 parent 2f77601 commit 73490d2
Show file tree
Hide file tree
Showing 3 changed files with 295 additions and 9 deletions.
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 = {
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);
}
};

// 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

0 comments on commit 73490d2

Please sign in to comment.