Skip to content

Commit

Permalink
NNS1-3480: Neurons Transactions (#5942)
Browse files Browse the repository at this point in the history
# 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
  • Loading branch information
yhabib authored Dec 9, 2024
1 parent ac72b1a commit c4543ef
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 154 deletions.
144 changes: 28 additions & 116 deletions frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -46,114 +34,38 @@
let swapCanisterAccounts: Set<string>;
let neuronAccounts: Set<string>;
let nnsAccounts: Account[];
let swapCanisterAccountsStore: Readable<Set<string>> | undefined;
let nnsNeurons: NeuronInfo[];
let swapCanisterAccountsStore: Readable<Set<string>>;
$: 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<CsvData>[] => {
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<CsvData>[] = [
const headers: CsvHeader<TransactionsCsvData>[] = [
{
id: "id",
label: $i18n.export_csv_neurons.transaction_id,
Expand Down
65 changes: 43 additions & 22 deletions frontend/src/lib/services/export-data.services.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<TransactionsAndAccounts> => {
const accountPromises = accounts.map((account) =>
}): Promise<TransactionResults> => {
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 ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();

Expand Down
Loading

0 comments on commit c4543ef

Please sign in to comment.