From c4543ef9de4036a66c54f7bdaff7cb2a67ea2182 Mon Sep 17 00:00:00 2001 From: Yusef Habib Date: Mon, 9 Dec 2024 06:58:19 +0100 Subject: [PATCH] NNS1-3480: Neurons Transactions (#5942) # Motivation We want to retrieve neuron transactions when generating a report for all transactions. We aim to request transactions for both Neurons and Accounts simultaneously, allowing us to parallelize these calls to the Ledger Canister. # Changes - Changes `getAccountTransactionsConcurrently` to handle generic entities(neurons or accounts) # Tests - Unit test for the new logic that parallelizes both types of entities: accounts and neurons. # Todos - [ ] Add entry to changelog (if necessary). Prev. PR: #5941 --- .../header/ExportIcpTransactionsButton.svelte | 144 ++++-------------- .../src/lib/services/export-data.services.ts | 65 +++++--- .../ExportIcpTransactionsButton.spec.ts | 11 +- .../lib/services/export-data.services.spec.ts | 106 +++++++++++-- 4 files changed, 172 insertions(+), 154 deletions(-) diff --git a/frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte b/frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte index 0bc06b0e6f6..a97d81100b6 100644 --- a/frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte +++ b/frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte @@ -2,40 +2,28 @@ import { i18n } from "$lib/stores/i18n"; import { IconDown } from "@dfinity/gix-components"; import { createEventDispatcher } from "svelte"; + import { ICPToken, isNullish } from "@dfinity/utils"; import { - ICPToken, - isNullish, - nonNullish, - TokenAmountV2, - } from "@dfinity/utils"; - import { + buildTransactionsDatasets, CsvGenerationError, FileSystemAccessError, generateCsvFileToSave, - type CsvDataset, type CsvHeader, + type TransactionsCsvData, } from "$lib/utils/export-to-csv.utils"; import { toastsError } from "$lib/stores/toasts.store"; - import { - formatDateCompact, - nanoSecondsToDateTime, - nowInBigIntNanoSeconds, - } from "$lib/utils/date.utils"; + import { formatDateCompact } from "$lib/utils/date.utils"; import { authStore } from "$lib/stores/auth.store"; import { nnsAccountsListStore } from "$lib/derived/accounts-list.derived"; - import { - getAccountTransactionsConcurrently, - type TransactionsAndAccounts, - } from "$lib/services/export-data.services"; + import { getAccountTransactionsConcurrently } from "$lib/services/export-data.services"; import { SignIdentity, type Identity } from "@dfinity/agent"; - import { mapIcpTransactionToReport } from "$lib/utils/icp-transactions.utils"; - import { neuronAccountsStore } from "$lib/derived/neurons.derived"; import { createSwapCanisterAccountsStore } from "$lib/derived/sns-swap-canisters-accounts.derived"; - import { transactionName } from "$lib/utils/transactions.utils"; - import { formatTokenV2 } from "$lib/utils/token.utils"; import { replacePlaceholders } from "$lib/utils/i18n.utils"; import type { Account } from "$lib/types/account"; + import { neuronAccountsStore } from "$lib/derived/neurons.derived"; import type { Readable } from "svelte/store"; + import { neuronsStore } from "$lib/stores/neurons.store"; + import type { NeuronInfo } from "@dfinity/nns"; const dispatcher = createEventDispatcher<{ nnsExportIcpTransactionsCsvTriggered: void; @@ -46,114 +34,38 @@ let swapCanisterAccounts: Set; let neuronAccounts: Set; let nnsAccounts: Account[]; - let swapCanisterAccountsStore: Readable> | undefined; + let nnsNeurons: NeuronInfo[]; + let swapCanisterAccountsStore: Readable>; $: identity = $authStore.identity; $: neuronAccounts = $neuronAccountsStore; $: nnsAccounts = $nnsAccountsListStore; - $: isDisabled = isNullish(identity) || nnsAccounts.length === 0; + $: nnsNeurons = $neuronsStore.neurons ?? []; + $: isDisabled = + isNullish(identity) || + (nnsAccounts.length === 0 && nnsNeurons.length === 0); $: swapCanisterAccountsStore = createSwapCanisterAccountsStore( identity?.getPrincipal() ); $: swapCanisterAccounts = $swapCanisterAccountsStore ?? new Set(); - type CsvData = { - id: string; - project: string; - symbol: string; - to: string | undefined; - from: string | undefined; - type: string; - amount: string; - timestamp: string; - }; - - const buildDatasets = ( - data: TransactionsAndAccounts - ): CsvDataset[] => { - return data.map(({ account, transactions }) => { - const amount = TokenAmountV2.fromUlps({ - amount: account.balanceUlps, - token: ICPToken, - }); - - return { - metadata: [ - { - label: $i18n.export_csv_neurons.account_id, - value: account.identifier, - }, - { - label: $i18n.export_csv_neurons.account_name, - value: account.name ?? $i18n.accounts.main, - }, - { - label: replacePlaceholders($i18n.export_csv_neurons.balance, { - $tokenSymbol: ICPToken.symbol, - }), - value: formatTokenV2({ - value: amount, - detailed: true, - }), - }, - { - label: $i18n.export_csv_neurons.controller_id, - value: - identity?.getPrincipal().toText() ?? $i18n.core.not_applicable, - }, - { - label: $i18n.export_csv_neurons.numer_of_transactions, - value: transactions.length.toString(), - }, - { - label: $i18n.export_csv_neurons.date_label, - value: nanoSecondsToDateTime(nowInBigIntNanoSeconds()), - }, - ], - data: transactions.map((transaction) => { - const { - to, - from, - type, - tokenAmount, - timestampNanos, - transactionDirection, - } = mapIcpTransactionToReport({ - accountIdentifier: account.identifier, - transaction, - neuronAccounts, - swapCanisterAccounts, - }); - - const sign = transactionDirection === "credit" ? "+" : "-"; - const amount = formatTokenV2({ value: tokenAmount, detailed: true }); - const timestamp = nonNullish(timestampNanos) - ? nanoSecondsToDateTime(timestampNanos) - : $i18n.core.not_applicable; - - return { - id: transaction.id.toString(), - project: ICPToken.name, - symbol: ICPToken.symbol, - to, - from, - type: transactionName({ type, i18n: $i18n }), - amount: `${sign}${amount}`, - timestamp, - }; - }), - }; - }); - }; - const exportIcpTransactions = async () => { try { - const data = await getAccountTransactionsConcurrently({ - accounts: nnsAccounts, - identity: identity as SignIdentity, + // we are logged in to be able to interact with the button + const signIdentity = identity as SignIdentity; + const entities = [...nnsAccounts, ...nnsNeurons]; + const transactions = await getAccountTransactionsConcurrently({ + entities, + identity: signIdentity, + }); + const datasets = buildTransactionsDatasets({ + transactions, + i18n: $i18n, + neuronAccounts, + swapCanisterAccounts, + principal: signIdentity.getPrincipal(), }); - const datasets = buildDatasets(data); - const headers: CsvHeader[] = [ + const headers: CsvHeader[] = [ { id: "id", label: $i18n.export_csv_neurons.transaction_id, diff --git a/frontend/src/lib/services/export-data.services.ts b/frontend/src/lib/services/export-data.services.ts index 660dd19758d..efd892d2c33 100644 --- a/frontend/src/lib/services/export-data.services.ts +++ b/frontend/src/lib/services/export-data.services.ts @@ -1,5 +1,6 @@ import { getTransactions } from "$lib/api/icp-index.api"; import type { Account } from "$lib/types/account"; +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"; @@ -19,58 +20,78 @@ type TransactionEntity = originalData: NeuronInfo; }; +const accountToTransactionEntity = (account: Account): TransactionEntity => { + return { + identifier: account.identifier, + type: "account", + balance: account.balanceUlps, + originalData: account, + }; +}; +const neuronToTransactionEntity = (neuron: NeuronInfo): TransactionEntity => { + return { + identifier: neuron.fullNeuron?.accountIdentifier || "", + balance: neuronStake(neuron), + type: "neuron", + originalData: neuron, + }; +}; +export const mapAccountOrNeuronToTransactionEntity = ( + entity: Account | NeuronInfo +): TransactionEntity => { + if ("neuronId" in entity) { + return neuronToTransactionEntity(entity); + } + return accountToTransactionEntity(entity); +}; + export type TransactionResults = { entity: TransactionEntity; transactions: TransactionWithId[]; error?: string; }[]; -export type TransactionsAndAccounts = { - account: Account; - transactions: TransactionWithId[]; - error?: string; -}[]; - export const getAccountTransactionsConcurrently = async ({ - accounts, + entities, identity, }: { - accounts: Account[]; + entities: (Account | NeuronInfo)[]; identity: SignIdentity; -}): Promise => { - const accountPromises = accounts.map((account) => +}): Promise => { + const transactionEntities = entities.map( + mapAccountOrNeuronToTransactionEntity + ); + + const transactionPromises = transactionEntities.map((entity) => getAllTransactionsFromAccountAndIdentity({ - accountId: account.identifier, + accountId: entity.identifier, identity, }) ); - const results = await Promise.allSettled(accountPromises); + const results = await Promise.allSettled(transactionPromises); - const accountsAndTransactions = results.map((result, index) => { - const account = accounts[index]; - const baseAccountInfo = { - account: { - ...account, - }, + const entitiesAndTransactions = results.map((result, index) => { + const entity = transactionEntities[index]; + const baseInfo = { + entity, }; if (result.status === "fulfilled") { return { - ...baseAccountInfo, + ...baseInfo, transactions: result.value ?? [], }; } else { - // TODO: At the moment, this path is not possible as getAllTransactionsFromAccountAndIdentity never throws an error. return { - ...baseAccountInfo, + ...baseInfo, transactions: [], error: result.reason?.message || "Failed to fetch transactions", }; } }); - return accountsAndTransactions; + return entitiesAndTransactions; }; export const getAllTransactionsFromAccountAndIdentity = async ({ diff --git a/frontend/src/tests/lib/components/header/ExportIcpTransactionsButton.spec.ts b/frontend/src/tests/lib/components/header/ExportIcpTransactionsButton.spec.ts index d26663b9d3e..82f1f191868 100644 --- a/frontend/src/tests/lib/components/header/ExportIcpTransactionsButton.spec.ts +++ b/frontend/src/tests/lib/components/header/ExportIcpTransactionsButton.spec.ts @@ -7,7 +7,10 @@ import { mockAccountsStoreData } from "$tests/mocks/icp-accounts.store.mock"; import { createTransactionWithId } from "$tests/mocks/icp-transactions.mock"; import { ExportIcpTransactionsButtonPo } from "$tests/page-objects/ExportIcpTransactionsButton.page-object"; import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; -import { setAccountsForTesting } from "$tests/utils/accounts.test-utils"; +import { + resetAccountsForTesting, + setAccountsForTesting, +} from "$tests/utils/accounts.test-utils"; import { runResolvedPromises } from "$tests/utils/timers.test-utils"; import { render } from "@testing-library/svelte"; @@ -69,6 +72,12 @@ describe("ExportIcpTransactionsButton", () => { expect(await po.isDisabled()).toBe(true); }); + it("should be disabled when there is no accounts nor neurons", async () => { + resetAccountsForTesting(); + const po = renderComponent(); + expect(await po.isDisabled()).toBe(true); + }); + it("should name the file with the date of the export", async () => { const po = renderComponent(); diff --git a/frontend/src/tests/lib/services/export-data.services.spec.ts b/frontend/src/tests/lib/services/export-data.services.spec.ts index 7c1f42976fc..873c9baa9b7 100644 --- a/frontend/src/tests/lib/services/export-data.services.spec.ts +++ b/frontend/src/tests/lib/services/export-data.services.spec.ts @@ -2,6 +2,7 @@ import * as icpIndexApi from "$lib/api/icp-index.api"; import { getAccountTransactionsConcurrently, getAllTransactionsFromAccountAndIdentity, + mapAccountOrNeuronToTransactionEntity, } from "$lib/services/export-data.services"; import { mockSignInIdentity } from "$tests/mocks/auth.store.mock"; import { @@ -9,6 +10,7 @@ import { mockSubAccount, } from "$tests/mocks/icp-accounts.store.mock"; import { createTransactionWithId } from "$tests/mocks/icp-transactions.mock"; +import { mockNeuron } from "$tests/mocks/neurons.mock"; import type { SignIdentity } from "@dfinity/agent"; vi.mock("$lib/api/icp-ledger.api"); @@ -135,44 +137,118 @@ describe("export-data service", () => { describe("getAccountTransactionsConcurrently", () => { const mockIdentity = {} as unknown as SignIdentity; - const mockAccounts = [mockMainAccount, mockSubAccount]; + const mockEntities = [mockMainAccount, mockSubAccount, mockNeuron]; const mockTransactions = [ createTransactionWithId({}), createTransactionWithId({}), ]; + const mainAccountEntity = { + originalData: mockMainAccount, + balance: mockMainAccount.balanceUlps, + identifier: mockMainAccount.identifier, + type: "account", + }; + + const subAccountEntity = { + originalData: mockSubAccount, + balance: mockSubAccount.balanceUlps, + identifier: mockSubAccount.identifier, + type: "account", + }; + + const neuronEntity = { + identifier: mockNeuron.fullNeuron?.accountIdentifier, + balance: 3000000000n, + originalData: mockNeuron, + type: "neuron", + }; + + it("should handle empty accounts array", async () => { + const result = await getAccountTransactionsConcurrently({ + entities: [], + identity: mockIdentity, + }); + + expect(result).toEqual([]); + expect(spyGetTransactions).toHaveBeenCalledTimes(0); + }); + + it("should map the MainAccount to a generic entity", async () => { + expect(mapAccountOrNeuronToTransactionEntity(mockMainAccount)).toEqual( + mainAccountEntity + ); + }); + + it("should map a SubAccount to a generic entity", async () => { + expect(mapAccountOrNeuronToTransactionEntity(mockSubAccount)).toEqual( + subAccountEntity + ); + }); + + it("should map a Neuron to a generic entity", async () => { + expect(mapAccountOrNeuronToTransactionEntity(mockNeuron)).toEqual( + neuronEntity + ); + }); + it("should fetch transactions for all accounts successfully", async () => { spyGetTransactions.mockResolvedValue({ transactions: mockTransactions, }); const result = await getAccountTransactionsConcurrently({ - accounts: mockAccounts, + entities: mockEntities, identity: mockIdentity, }); - expect(result).toHaveLength(mockAccounts.length); - expect(spyGetTransactions).toHaveBeenCalledTimes(mockAccounts.length); + expect(result).toHaveLength(3); + expect(spyGetTransactions).toHaveBeenCalledTimes(3); - result.forEach((accountResult, index) => { - expect(accountResult.account).toEqual(mockAccounts[index]); - expect(accountResult.transactions).toEqual(mockTransactions); - expect(accountResult.error).toBeUndefined(); - }); + expect(result[0].entity).toEqual(mainAccountEntity); + expect(result[0].transactions).toEqual(mockTransactions); + expect(result[0].error).toBeUndefined(); + + expect(result[1].entity).toEqual(subAccountEntity); + expect(result[1].transactions).toEqual(mockTransactions); + expect(result[1].error).toBeUndefined(); + + expect(result[2].entity).toEqual(neuronEntity); + expect(result[2].transactions).toEqual(mockTransactions); + expect(result[2].error).toBeUndefined(); }); - // TODO: To be implemented once getAllTransactionsFromAccountAndIdentity handles better errors - it.skip("should handle failed transactions fetch for some accounts", async () => {}); + // TODO: To be implemented once getAccountTransactionsConcurrently handles errors + it.skip("should handle failed transactions fetch for some accounts", async () => { + spyGetTransactions + .mockResolvedValueOnce({ + transactions: mockTransactions, + }) + .mockRejectedValueOnce(new Error("API Error")) + .mockResolvedValueOnce({ + transactions: mockTransactions, + }); - it("should handle empty accounts array", async () => { const result = await getAccountTransactionsConcurrently({ - accounts: [], + entities: mockEntities, identity: mockIdentity, }); - expect(result).toEqual([]); - expect(spyGetTransactions).toHaveBeenCalledTimes(0); + expect(result).toHaveLength(3); + expect(spyGetTransactions).toHaveBeenCalledTimes(3); + + expect(result[0].entity).toEqual(mainAccountEntity); + expect(result[0].transactions).toEqual(mockTransactions); + expect(result[0].error).toBeUndefined(); + + expect(result[1].entity).toEqual(subAccountEntity); + expect(result[1].transactions).toEqual([]); + expect(result[1].error).toBeDefined(); + + expect(result[2].entity).toEqual(neuronEntity); + expect(result[2].transactions).toEqual(mockTransactions); + expect(result[2].error).toBeUndefined(); }); }); });