Skip to content

Commit

Permalink
NNS1-3480: new Export ICP Transactions button (#5887)
Browse files Browse the repository at this point in the history
# Motivation

To generate a Csv file with transaction information shows in the
`/wallet` page of an account.
It can be tested
[here](https://qsgjb-riaaa-aaaaa-aaaga-cai.yhabib-ingress.devenv.dfinity.network/)

# Changes

- New component to create and initialize the download of a CSV file
containing information about all transactions for all user accounts.

# Tests

- Component Unit Tests

# Todos

- [ ] Add entry to changelog (if necessary).
Not necessary

Prev. PR: #5895 | Next PR: #5926
  • Loading branch information
yhabib authored Dec 6, 2024
1 parent 3654e6b commit d516f88
Show file tree
Hide file tree
Showing 6 changed files with 509 additions and 20 deletions.
239 changes: 239 additions & 0 deletions frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
<script lang="ts">
import { i18n } from "$lib/stores/i18n";
import { IconDown } from "@dfinity/gix-components";
import { createEventDispatcher } from "svelte";
import {
ICPToken,
isNullish,
nonNullish,
TokenAmountV2,
} from "@dfinity/utils";
import {
CsvGenerationError,
FileSystemAccessError,
generateCsvFileToSave,
type CsvDataset,
type CsvHeader,
} from "$lib/utils/export-to-csv.utils";
import { toastsError } from "$lib/stores/toasts.store";
import {
formatDateCompact,
nanoSecondsToDateTime,
nowInBigIntNanoSeconds,
} 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 { 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 type { Readable } from "svelte/store";
const dispatcher = createEventDispatcher<{
nnsExportIcpTransactionsCsvTriggered: void;
}>();
let isDisabled = true;
let identity: Identity | null | undefined;
let swapCanisterAccounts: Set<string>;
let neuronAccounts: Set<string>;
let nnsAccounts: Account[];
let swapCanisterAccountsStore: Readable<Set<string>> | undefined;
$: identity = $authStore.identity;
$: neuronAccounts = $neuronAccountsStore;
$: nnsAccounts = $nnsAccountsListStore;
$: isDisabled = isNullish(identity) || nnsAccounts.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,
});
const datasets = buildDatasets(data);
const headers: CsvHeader<CsvData>[] = [
{
id: "id",
label: $i18n.export_csv_neurons.transaction_id,
},
{
id: "project",
label: $i18n.export_csv_neurons.project,
},
{
id: "symbol",
label: $i18n.export_csv_neurons.symbol,
},
{
id: "to",
label: $i18n.export_csv_neurons.to,
},
{
id: "from",
label: $i18n.export_csv_neurons.from,
},
{
id: "type",
label: $i18n.export_csv_neurons.transaction_type,
},
{
id: "amount",
label: replacePlaceholders($i18n.export_csv_neurons.amount, {
$tokenSymbol: ICPToken.symbol,
}),
},
{
id: "timestamp",
label: $i18n.export_csv_neurons.timestamp,
},
];
const fileName = `icp_transactions_export_${formatDateCompact(new Date())}`;
await generateCsvFileToSave({
datasets,
headers,
fileName,
});
} catch (error) {
console.error("Error exporting neurons:", error);
if (error instanceof FileSystemAccessError) {
toastsError({
labelKey: "export_error.file_system_access",
});
} else if (error instanceof CsvGenerationError) {
toastsError({
labelKey: "export_error.csv_generation",
});
} else {
toastsError({
labelKey: "export_error.neurons",
});
}
} finally {
dispatcher("nnsExportIcpTransactionsCsvTriggered");
}
};
</script>

<button
data-tid="export-icp-transactions-button-component"
on:click={exportIcpTransactions}
class="text"
disabled={isDisabled}
aria-label={$i18n.header.export_neurons}
>
<IconDown />
{$i18n.header.export_transactions}
</button>

<style lang="scss">
@use "../../themes/mixins/account-menu";
button {
@include account-menu.button;
padding: 0;
}
</style>
31 changes: 21 additions & 10 deletions frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,23 +154,34 @@
"account_menu": "Open menu to access logout button",
"main_icp_account_id": "Main ICP Account ID",
"account_id_tooltip": "You can send ICP both to your principal ID and account ID, however some exchanges or wallets may not support transactions using a principal ID.",
"export_neurons": "Export Neurons Info"
"export_neurons": "Export Neurons Info",
"export_transactions": "Export ICP Transactions"
},
"export_csv_neurons": {
"account_id": "Account ID",
"account_id_label": "NNS Account Principal ID",
"date_label": "Export Date Time",
"account_name": "Account Name",
"amount": "Amount($tokenSymbol)",
"available_maturity": "Available Maturity",
"balance": "Balance($tokenSymbol)",
"controller_id": "Controller Principal ID",
"neuron_id": "Neuron ID",
"project": "Project",
"symbol": "Symbol",
"creation_date": "Creation Date",
"date_label": "Export Date Time",
"dissolve_date": "Dissolve Date",
"dissolve_delay": "Dissolve Delay",
"from": "From",
"neuron_account_id": "Neuron Account ID",
"neuron_id": "Neuron ID",
"numer_of_transactions": "Transactions",
"project": "Project Name",
"stake": "Stake",
"available_maturity": "Available Maturity",
"staked_maturity": "Staked Maturity",
"dissolve_delay": "Dissolve Delay",
"dissolve_date": "Dissolve Date",
"creation_date": "Creation Date",
"state": "State"
"state": "State",
"symbol": "Symbol",
"timestamp": "Date Time",
"to": "To",
"transaction_id": "TX ID",
"transaction_type": "TX Type"
},
"export_error": {
"csv_generation": "Failed to generate CSV file",
Expand Down
25 changes: 18 additions & 7 deletions frontend/src/lib/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,23 +162,34 @@ interface I18nHeader {
main_icp_account_id: string;
account_id_tooltip: string;
export_neurons: string;
export_transactions: string;
}

interface I18nExport_csv_neurons {
account_id: string;
account_id_label: string;
date_label: string;
account_name: string;
amount: string;
available_maturity: string;
balance: string;
controller_id: string;
creation_date: string;
date_label: string;
dissolve_date: string;
dissolve_delay: string;
from: string;
neuron_account_id: string;
neuron_id: string;
numer_of_transactions: string;
project: string;
symbol: string;
neuron_account_id: string;
stake: string;
available_maturity: string;
staked_maturity: string;
dissolve_delay: string;
dissolve_date: string;
creation_date: string;
state: string;
symbol: string;
timestamp: string;
to: string;
transaction_id: string;
transaction_type: string;
}

interface I18nExport_error {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/lib/utils/export-to-csv.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ type Metadata = {
value: string;
};

type Dataset<T> = {
export type CsvDataset<T> = {
data: T[];
metadata?: Metadata[];
};
Expand Down Expand Up @@ -165,7 +165,7 @@ export const combineDatasetsToCsv = <T>({
headers,
}: {
headers: CsvHeader<T>[];
datasets: Dataset<T>[];
datasets: CsvDataset<T>[];
}): string => {
const csvParts: string[] = [];
// A double empty line break requires 3 new lines
Expand Down Expand Up @@ -208,7 +208,7 @@ export const generateCsvFileToSave = async <T>({
fileName?: string;
description?: string;
headers: CsvHeader<T>[];
datasets: Dataset<T>[];
datasets: CsvDataset<T>[];
}): Promise<void> => {
try {
const csvContent = combineDatasetsToCsv({ datasets, headers });
Expand Down
Loading

0 comments on commit d516f88

Please sign in to comment.