From bcd5642f03e64869c5984c5bfa37984278efc2bc Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Mon, 11 Dec 2023 23:19:41 +0200 Subject: [PATCH 01/10] feat: Improve Address Transaction History export (#853) Signed-off-by: Eugene Panteleymonchuk --- .../transactionhistory/download/post.ts | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index 5d2633d78..5477c29f5 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -1,13 +1,16 @@ import JSZip from "jszip"; +import moment from "moment"; import { ServiceFactory } from "../../../../factories/serviceFactory"; import logger from "../../../../logger"; import { IDataResponse } from "../../../../models/api/IDataResponse"; import { ITransactionHistoryDownloadBody } from "../../../../models/api/stardust/chronicle/ITransactionHistoryDownloadBody"; import { ITransactionHistoryRequest } from "../../../../models/api/stardust/chronicle/ITransactionHistoryRequest"; +import { IOutputDetailsResponse } from "../../../../models/api/stardust/IOutputDetailsResponse"; import { IConfiguration } from "../../../../models/configuration/IConfiguration"; import { STARDUST } from "../../../../models/db/protocolVersion"; import { NetworkService } from "../../../../services/networkService"; import { ChronicleService } from "../../../../services/stardust/chronicleService"; +import { StardustTangleHelper } from "../../../../utils/stardust/stardustTangleHelper"; import { ValidationHelper } from "../../../../utils/validationHelper"; /** @@ -42,11 +45,47 @@ export async function post( const result = await chronicleService.transactionHistoryDownload(request.address, body.targetDate); + const outputDetails: IOutputDetailsResponse[] = await Promise.all(result.items.map(async item => + StardustTangleHelper.outputDetails(networkConfig, item.outputId) + )); + + const changeBalanceByTransactionId = new Map(); + + for (const details of outputDetails) { + const metadata = details.output.metadata; + const amount = Number(details.output.output.amount); + const timestamp = metadata.isSpent ? metadata.milestoneTimestampSpent : metadata.milestoneTimestampBooked; + const transactionId = metadata.isSpent ? metadata.transactionIdSpent : metadata.transactionId; + + const initTransactionInfo = { + balance: 0, + timestamp: 0 + }; + if (!changeBalanceByTransactionId.has(transactionId)) { + changeBalanceByTransactionId.set(transactionId, initTransactionInfo); + } + const prev = changeBalanceByTransactionId.get(transactionId); + prev.balance = metadata?.isSpent ? prev.balance - amount : prev.balance + amount; + prev.timestamp = Math.max(timestamp * 1000, prev.timestamp); + changeBalanceByTransactionId.set(transactionId, prev); + } + + const headers = ["Timestamp", "TransactionId", "Balance changes"]; + + let csvContent = `${headers.join(",")}\n`; + + for (const key of changeBalanceByTransactionId.keys()) { + const value = changeBalanceByTransactionId.get(key); + const row = [moment(value.timestamp).format("YYYY-MM-DD HH:mm:ss"), key, value.balance].join(","); + csvContent += `${row}\n`; + } + + const jsZip = new JSZip(); let response: IDataResponse = null; try { - jsZip.file("history.json", JSON.stringify(result)); + jsZip.file("history.csv", csvContent); const content = await jsZip.generateAsync({ type: "nodebuffer" }); response = { @@ -60,3 +99,4 @@ export async function post( return response; } + From b071b715f00f0b5b0f5316a6c278f2701b86591e Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Thu, 25 Jan 2024 10:45:09 +0200 Subject: [PATCH 02/10] chore: request output details. Signed-off-by: Eugene Panteleymonchuk --- .../transactionhistory/download/post.ts | 149 +++++++++++------- 1 file changed, 96 insertions(+), 53 deletions(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index 8f63705c0..2c80248ec 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -1,17 +1,21 @@ -import JSZip from "jszip"; -import moment from "moment"; +// import JSZip from "jszip"; +// import moment from "moment"; +import { OutputResponse } from "@iota/sdk"; import { ServiceFactory } from "../../../../factories/serviceFactory"; -import logger from "../../../../logger"; +// import logger from "../../../../logger"; import { IDataResponse } from "../../../../models/api/IDataResponse"; import { ITransactionHistoryDownloadBody } from "../../../../models/api/stardust/chronicle/ITransactionHistoryDownloadBody"; import { ITransactionHistoryRequest } from "../../../../models/api/stardust/chronicle/ITransactionHistoryRequest"; +import { ITransactionHistoryResponse } from "../../../../models/api/stardust/chronicle/ITransactionHistoryResponse"; import { IOutputDetailsResponse } from "../../../../models/api/stardust/IOutputDetailsResponse"; import { IConfiguration } from "../../../../models/configuration/IConfiguration"; import { STARDUST } from "../../../../models/db/protocolVersion"; import { NetworkService } from "../../../../services/networkService"; import { ChronicleService } from "../../../../services/stardust/chronicleService"; -import { StardustTangleHelper } from "../../../../utils/stardust/stardustTangleHelper"; +// import { StardustTangleHelper } from "../../../../utils/stardust/stardustTangleHelper"; +import { StardustApiService } from "../../../../services/stardust/stardustApiService"; import { ValidationHelper } from "../../../../utils/validationHelper"; +export type OutputWithDetails = ITransactionHistoryResponse & { details: OutputResponse | null; amount?: string }; /** * Download the transaction history from chronicle stardust. @@ -40,58 +44,97 @@ export async function post( } const chronicleService = ServiceFactory.get(`chronicle-${networkConfig.network}`); + const apiService = ServiceFactory.get(`api-service-${networkConfig.network}`); - const result = await chronicleService.transactionHistoryDownload(request.address, body.targetDate); + const outputs = await chronicleService.transactionHistoryDownload(request.address, body.targetDate); - const outputDetails: IOutputDetailsResponse[] = await Promise.all( - result.items.map(async (item) => StardustTangleHelper.outputDetails(networkConfig, item.outputId)), - ); - - const changeBalanceByTransactionId = new Map(); - - for (const details of outputDetails) { - const metadata = details.output.metadata; - const amount = Number(details.output.output.amount); - const timestamp = metadata.isSpent ? metadata.milestoneTimestampSpent : metadata.milestoneTimestampBooked; - const transactionId = metadata.isSpent ? metadata.transactionIdSpent : metadata.transactionId; - - const initTransactionInfo = { - balance: 0, - timestamp: 0, - }; - if (!changeBalanceByTransactionId.has(transactionId)) { - changeBalanceByTransactionId.set(transactionId, initTransactionInfo); + const requestOutputDetails = async (outputId: string): Promise => { + if (!outputId) { + return null; } - const prev = changeBalanceByTransactionId.get(transactionId); - prev.balance = metadata?.isSpent ? prev.balance - amount : prev.balance + amount; - prev.timestamp = Math.max(timestamp * 1000, prev.timestamp); - changeBalanceByTransactionId.set(transactionId, prev); - } - - const headers = ["Timestamp", "TransactionId", "Balance changes"]; - - let csvContent = `${headers.join(",")}\n`; - for (const key of changeBalanceByTransactionId.keys()) { - const value = changeBalanceByTransactionId.get(key); - const row = [moment(value.timestamp).format("YYYY-MM-DD HH:mm:ss"), key, value.balance].join(","); - csvContent += `${row}\n`; - } - - const jsZip = new JSZip(); - let response: IDataResponse = null; - - try { - jsZip.file("history.csv", csvContent); - const content = await jsZip.generateAsync({ type: "nodebuffer" }); - - response = { - data: content, - contentType: "application/octet-stream", - }; - } catch (e) { - logger.error(`Failed to zip transaction history for download. Cause: ${e}`); - } + try { + const response = await apiService.outputDetails(outputId); + const details = response.output; + + if (!response.error && details?.output && details?.metadata) { + return details; + } + return null; + } catch { + console.log("Failed loading transaction history details!"); + return null; + } + }; + + const fulfilledOutputs: OutputWithDetails[] = await Promise.all( + outputs.items.map(async (output) => { + const details = await requestOutputDetails(output.outputId); + return { + ...output, + details, + amount: details?.output?.amount, + }; + }), + ); - return response; + console.log('--- fulfilledOutputs', fulfilledOutputs); + + // let transactionIdToOutputs = new Map(); + + + + // const outputDetails: IOutputDetailsResponse[] = await Promise.all( + // outputs.items.map(async (item) => apiService.outputDetails(item.outputId)), + // ); + // console.log("--- outputDetails", outputDetails); + // + // const changeBalanceByTransactionId = new Map(); + // + // for (const details of outputDetails) { + // const metadata = details.output.metadata; + // const amount = Number(details.output.output.amount); + // const timestamp = metadata.isSpent ? metadata.milestoneTimestampSpent : metadata.milestoneTimestampBooked; + // const transactionId = metadata.isSpent ? metadata.transactionIdSpent : metadata.transactionId; + // + // const initTransactionInfo = { + // balance: 0, + // timestamp: 0, + // }; + // if (!changeBalanceByTransactionId.has(transactionId)) { + // changeBalanceByTransactionId.set(transactionId, initTransactionInfo); + // } + // const prev = changeBalanceByTransactionId.get(transactionId); + // prev.balance = metadata?.isSpent ? prev.balance - amount : prev.balance + amount; + // prev.timestamp = Math.max(timestamp * 1000, prev.timestamp); + // changeBalanceByTransactionId.set(transactionId, prev); + // } + // + // const headers = ["Timestamp", "TransactionId", "Balance changes"]; + // + // let csvContent = `${headers.join(",")}\n`; + // + // for (const key of changeBalanceByTransactionId.keys()) { + // const value = changeBalanceByTransactionId.get(key); + // const row = [moment(value.timestamp).format("YYYY-MM-DD HH:mm:ss"), key, value.balance].join(","); + // csvContent += `${row}\n`; + // } + // + // const jsZip = new JSZip(); + // let response: IDataResponse = null; + // + // try { + // jsZip.file("history.csv", csvContent); + // const content = await jsZip.generateAsync({ type: "nodebuffer" }); + // + // response = { + // data: content, + // contentType: "application/octet-stream", + // }; + // } catch (e) { + // logger.error(`Failed to zip transaction history for download. Cause: ${e}`); + // } + + // return response; + return null; } From c5a5eb540e1fb2f7797262c8febc7f4b97d57c3e Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Tue, 30 Jan 2024 12:15:57 +0200 Subject: [PATCH 03/10] chore: calculate transactions. Signed-off-by: Eugene Panteleymonchuk --- .../transactionhistory/download/post.ts | 118 ++++++++++++++++-- 1 file changed, 110 insertions(+), 8 deletions(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index 2c80248ec..a90bde52e 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -1,13 +1,22 @@ // import JSZip from "jszip"; -// import moment from "moment"; -import { OutputResponse } from "@iota/sdk"; +import { + OutputResponse, + // INodeInfoBaseToken, + CommonOutput, +} from "@iota/sdk"; +// import { Utils } from "@iota/sdk-wasm/web"; +// Utils. +import moment from "moment"; import { ServiceFactory } from "../../../../factories/serviceFactory"; // import logger from "../../../../logger"; import { IDataResponse } from "../../../../models/api/IDataResponse"; import { ITransactionHistoryDownloadBody } from "../../../../models/api/stardust/chronicle/ITransactionHistoryDownloadBody"; import { ITransactionHistoryRequest } from "../../../../models/api/stardust/chronicle/ITransactionHistoryRequest"; -import { ITransactionHistoryResponse } from "../../../../models/api/stardust/chronicle/ITransactionHistoryResponse"; -import { IOutputDetailsResponse } from "../../../../models/api/stardust/IOutputDetailsResponse"; +import { + // ITransactionHistoryResponse, + ITransactionHistoryItem, +} from "../../../../models/api/stardust/chronicle/ITransactionHistoryResponse"; +// import { IOutputDetailsResponse } from "../../../../models/api/stardust/IOutputDetailsResponse"; import { IConfiguration } from "../../../../models/configuration/IConfiguration"; import { STARDUST } from "../../../../models/db/protocolVersion"; import { NetworkService } from "../../../../services/networkService"; @@ -15,7 +24,18 @@ import { ChronicleService } from "../../../../services/stardust/chronicleService // import { StardustTangleHelper } from "../../../../utils/stardust/stardustTangleHelper"; import { StardustApiService } from "../../../../services/stardust/stardustApiService"; import { ValidationHelper } from "../../../../utils/validationHelper"; -export type OutputWithDetails = ITransactionHistoryResponse & { details: OutputResponse | null; amount?: string }; +export type OutputWithDetails = ITransactionHistoryItem & { details: OutputResponse | null; amount?: string }; + +export interface ITransactionHistoryRecord { + isGenesisByDate: boolean; + isSpent: boolean; + transactionId: string; + timestamp: number; + dateFormatted: string; + balanceChange: number; + balanceChangeFormatted: string; + outputs: OutputWithDetails[]; +} /** * Download the transaction history from chronicle stardust. @@ -78,12 +98,21 @@ export async function post( }), ); - console.log('--- fulfilledOutputs', fulfilledOutputs); - - // let transactionIdToOutputs = new Map(); + fulfilledOutputs.sort((a, b) => { + // Ensure that entries with equal timestamp, but different isSpent, + // have the spending before the depositing + if (a.milestoneTimestamp === b.milestoneTimestamp && a.isSpent !== b.isSpent) { + return a.isSpent ? 1 : -1; + } + return 1; + }); + const transactionIdToOutputs = groupOutputsByTransactionId(fulfilledOutputs); + const transactions = getTransactionHistoryRecords(transactionIdToOutputs); + console.log("--- transactions", transactions); + // let transactionIdToOutputs = new Map(); // const outputDetails: IOutputDetailsResponse[] = await Promise.all( // outputs.items.map(async (item) => apiService.outputDetails(item.outputId)), // ); @@ -138,3 +167,76 @@ export async function post( // return response; return null; } + +export const groupOutputsByTransactionId = (outputsWithDetails: OutputWithDetails[]) => { + const transactionIdToOutputs = new Map(); + for (const output of outputsWithDetails) { + const detailsMetadata = output?.details?.metadata; + if (!detailsMetadata) { + // eslint-disable-next-line no-continue + continue; + } + + const transactionId = output.isSpent ? detailsMetadata.transactionIdSpent : detailsMetadata.transactionId; + + if (!transactionId) { + // eslint-disable-next-line no-continue + continue; + } + + // if we don't have the transaction + const previousOutputs = transactionIdToOutputs.get(transactionId); + if (previousOutputs) { + transactionIdToOutputs.set(transactionId, [...previousOutputs, output]); + } else { + transactionIdToOutputs.set(transactionId, [output]); + } + } + + return transactionIdToOutputs; +}; + +export const getTransactionHistoryRecords = (transactionIdToOutputs: Map): ITransactionHistoryRecord[] => { + const calculatedTransactions: ITransactionHistoryRecord[] = []; + + for (const [transactionId, outputs] of transactionIdToOutputs.entries()) { + const lastOutputTime = Math.max(...outputs.map((t) => t.milestoneTimestamp)); + const balanceChange = calculateBalanceChange(outputs); + const ago = moment(lastOutputTime * 1000).fromNow(); + + const isGenesisByDate = outputs.map((t) => t.milestoneTimestamp).includes(0); + + const isSpent = balanceChange <= 0; + + calculatedTransactions.push({ + isGenesisByDate, + isSpent, + transactionId, + timestamp: lastOutputTime, + dateFormatted: `${moment(lastOutputTime * 1000).format("YYYY-MM-DD HH:mm:ss")} (${ago})`, + balanceChange, + balanceChangeFormatted: (isSpent ? "-" : "+") + Math.abs(balanceChange), + outputs, + }); + } + return calculatedTransactions; +}; + +export const calculateBalanceChange = (outputs: OutputWithDetails[]) => { + // eslint-disable-next-line unicorn/no-array-reduce + return outputs.reduce((acc, output) => { + const outputFromDetails = output?.details?.output as CommonOutput; + + if (!outputFromDetails?.amount) { + console.warn("Output details not found for:", output); + return acc; + } + + let amount = Number(outputFromDetails.amount); + if (output.isSpent) { + // eslint-disable-next-line operator-assignment + amount = -1 * amount; + } + return acc + amount; + }, 0); +}; From 23721c2a6e68f6363578b580368f7fb0e6037a74 Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Tue, 30 Jan 2024 13:45:02 +0200 Subject: [PATCH 04/10] chore: export done. Signed-off-by: Eugene Panteleymonchuk --- .../transactionhistory/download/post.ts | 221 ++++++++++-------- 1 file changed, 121 insertions(+), 100 deletions(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index a90bde52e..37bb3734f 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -1,27 +1,17 @@ -// import JSZip from "jszip"; -import { - OutputResponse, - // INodeInfoBaseToken, - CommonOutput, -} from "@iota/sdk"; -// import { Utils } from "@iota/sdk-wasm/web"; -// Utils. +import { OutputResponse, INodeInfoBaseToken, CommonOutput } from "@iota/sdk"; +import JSZip from "jszip"; import moment from "moment"; import { ServiceFactory } from "../../../../factories/serviceFactory"; -// import logger from "../../../../logger"; +import logger from "../../../../logger"; import { IDataResponse } from "../../../../models/api/IDataResponse"; import { ITransactionHistoryDownloadBody } from "../../../../models/api/stardust/chronicle/ITransactionHistoryDownloadBody"; import { ITransactionHistoryRequest } from "../../../../models/api/stardust/chronicle/ITransactionHistoryRequest"; -import { - // ITransactionHistoryResponse, - ITransactionHistoryItem, -} from "../../../../models/api/stardust/chronicle/ITransactionHistoryResponse"; -// import { IOutputDetailsResponse } from "../../../../models/api/stardust/IOutputDetailsResponse"; +import { ITransactionHistoryItem } from "../../../../models/api/stardust/chronicle/ITransactionHistoryResponse"; import { IConfiguration } from "../../../../models/configuration/IConfiguration"; import { STARDUST } from "../../../../models/db/protocolVersion"; import { NetworkService } from "../../../../services/networkService"; import { ChronicleService } from "../../../../services/stardust/chronicleService"; -// import { StardustTangleHelper } from "../../../../utils/stardust/stardustTangleHelper"; +import { NodeInfoService } from "../../../../services/stardust/nodeInfoService"; import { StardustApiService } from "../../../../services/stardust/stardustApiService"; import { ValidationHelper } from "../../../../utils/validationHelper"; export type OutputWithDetails = ITransactionHistoryItem & { details: OutputResponse | null; amount?: string }; @@ -55,6 +45,9 @@ export async function post( const networkConfig = networkService.get(request.network); + const nodeInfoService = ServiceFactory.get(`node-info-${request.network}`); + const tokenInfo = nodeInfoService.getNodeInfo().baseToken; + if (networkConfig.protocolVersion !== STARDUST) { return null; } @@ -64,32 +57,12 @@ export async function post( } const chronicleService = ServiceFactory.get(`chronicle-${networkConfig.network}`); - const apiService = ServiceFactory.get(`api-service-${networkConfig.network}`); const outputs = await chronicleService.transactionHistoryDownload(request.address, body.targetDate); - const requestOutputDetails = async (outputId: string): Promise => { - if (!outputId) { - return null; - } - - try { - const response = await apiService.outputDetails(outputId); - const details = response.output; - - if (!response.error && details?.output && details?.metadata) { - return details; - } - return null; - } catch { - console.log("Failed loading transaction history details!"); - return null; - } - }; - const fulfilledOutputs: OutputWithDetails[] = await Promise.all( outputs.items.map(async (output) => { - const details = await requestOutputDetails(output.outputId); + const details = await requestOutputDetails(output.outputId, networkConfig.network); return { ...output, details, @@ -108,66 +81,56 @@ export async function post( }); const transactionIdToOutputs = groupOutputsByTransactionId(fulfilledOutputs); - const transactions = getTransactionHistoryRecords(transactionIdToOutputs); - - console.log("--- transactions", transactions); - - // let transactionIdToOutputs = new Map(); - // const outputDetails: IOutputDetailsResponse[] = await Promise.all( - // outputs.items.map(async (item) => apiService.outputDetails(item.outputId)), - // ); - // console.log("--- outputDetails", outputDetails); - // - // const changeBalanceByTransactionId = new Map(); - // - // for (const details of outputDetails) { - // const metadata = details.output.metadata; - // const amount = Number(details.output.output.amount); - // const timestamp = metadata.isSpent ? metadata.milestoneTimestampSpent : metadata.milestoneTimestampBooked; - // const transactionId = metadata.isSpent ? metadata.transactionIdSpent : metadata.transactionId; - // - // const initTransactionInfo = { - // balance: 0, - // timestamp: 0, - // }; - // if (!changeBalanceByTransactionId.has(transactionId)) { - // changeBalanceByTransactionId.set(transactionId, initTransactionInfo); - // } - // const prev = changeBalanceByTransactionId.get(transactionId); - // prev.balance = metadata?.isSpent ? prev.balance - amount : prev.balance + amount; - // prev.timestamp = Math.max(timestamp * 1000, prev.timestamp); - // changeBalanceByTransactionId.set(transactionId, prev); - // } - // - // const headers = ["Timestamp", "TransactionId", "Balance changes"]; - // - // let csvContent = `${headers.join(",")}\n`; - // - // for (const key of changeBalanceByTransactionId.keys()) { - // const value = changeBalanceByTransactionId.get(key); - // const row = [moment(value.timestamp).format("YYYY-MM-DD HH:mm:ss"), key, value.balance].join(","); - // csvContent += `${row}\n`; - // } - // - // const jsZip = new JSZip(); - // let response: IDataResponse = null; - // - // try { - // jsZip.file("history.csv", csvContent); - // const content = await jsZip.generateAsync({ type: "nodebuffer" }); - // - // response = { - // data: content, - // contentType: "application/octet-stream", - // }; - // } catch (e) { - // logger.error(`Failed to zip transaction history for download. Cause: ${e}`); - // } - - // return response; - return null; + const transactions = getTransactionHistoryRecords(transactionIdToOutputs, tokenInfo); + + const headers = ["Timestamp", "TransactionId", "Balance changes"]; + + let csvContent = `${headers.join(",")}\n`; + + for (const transaction of transactions) { + const row = [transaction.dateFormatted, transaction.transactionId, transaction.balanceChangeFormatted].join(","); + csvContent += `${row}\n`; + } + + const jsZip = new JSZip(); + let response: IDataResponse = null; + + try { + jsZip.file("history.csv", csvContent); + const content = await jsZip.generateAsync({ type: "nodebuffer" }); + + response = { + data: content, + contentType: "application/octet-stream", + }; + } catch (e) { + logger.error(`Failed to zip transaction history for download. Cause: ${e}`); + } + + return response; } +const requestOutputDetails = async (outputId: string, network: string): Promise => { + if (!outputId) { + return null; + } + + const apiService = ServiceFactory.get(`api-service-${network}`); + + try { + const response = await apiService.outputDetails(outputId); + const details = response.output; + + if (!response.error && details?.output && details?.metadata) { + return details; + } + return null; + } catch { + console.log("Failed loading transaction history details!"); + return null; + } +}; + export const groupOutputsByTransactionId = (outputsWithDetails: OutputWithDetails[]) => { const transactionIdToOutputs = new Map(); for (const output of outputsWithDetails) { @@ -196,13 +159,15 @@ export const groupOutputsByTransactionId = (outputsWithDetails: OutputWithDetail return transactionIdToOutputs; }; -export const getTransactionHistoryRecords = (transactionIdToOutputs: Map): ITransactionHistoryRecord[] => { +export const getTransactionHistoryRecords = ( + transactionIdToOutputs: Map, + tokenInfo: INodeInfoBaseToken, +): ITransactionHistoryRecord[] => { const calculatedTransactions: ITransactionHistoryRecord[] = []; for (const [transactionId, outputs] of transactionIdToOutputs.entries()) { const lastOutputTime = Math.max(...outputs.map((t) => t.milestoneTimestamp)); const balanceChange = calculateBalanceChange(outputs); - const ago = moment(lastOutputTime * 1000).fromNow(); const isGenesisByDate = outputs.map((t) => t.milestoneTimestamp).includes(0); @@ -213,9 +178,9 @@ export const getTransactionHistoryRecords = (transactionIdToOutputs: Map { let amount = Number(outputFromDetails.amount); if (output.isSpent) { - // eslint-disable-next-line operator-assignment - amount = -1 * amount; + amount *= -1; } return acc + amount; }, 0); }; + +/** + * Formats a numeric value into a string using token information and specified formatting rules. + * + * @param {number} value - The value to format. + * @param {INodeInfoBaseToken} tokenInfo - Information about the token, including units and decimals. + * @param {boolean} [formatFull=false] - If true, formats the entire number. Otherwise, uses decimalPlaces. + * @param {number} [decimalPlaces=2] - Number of decimal places in the formatted output. + * @param {boolean} [trailingDecimals] - Determines inclusion of trailing zeros in decimals. + * @returns {string} The formatted amount with the token unit. + */ +export function formatAmount( + value: number, + tokenInfo: INodeInfoBaseToken, + formatFull: boolean = false, + decimalPlaces: number = 2, + trailingDecimals?: boolean, +): string { + if (formatFull) { + return `${value} ${tokenInfo.subunit ?? tokenInfo.unit}`; + } + + const baseTokenValue = value / Math.pow(10, tokenInfo.decimals); + const formattedAmount = toFixedNoRound(baseTokenValue, decimalPlaces, trailingDecimals); + + return `${formattedAmount} ${tokenInfo.unit}`; +} + +/** + * Format amount to two decimal places without rounding off. + * @param value The raw amount to format. + * @param precision The decimal places to show. + * @param trailingDecimals Whether to show trailing decimals. + * @returns The formatted amount. + */ +function toFixedNoRound(value: number, precision: number = 2, trailingDecimals?: boolean): string { + const defaultDecimals = "0".repeat(precision); + const valueString = `${value}`; + const [integer, fraction = defaultDecimals] = valueString.split("."); + + if (fraction === defaultDecimals && !trailingDecimals) { + return valueString; + } + + if (!precision) { + return integer; + } + + const truncatedFraction = fraction.slice(0, precision); + + // avoid 0.00 case + if (!Number(truncatedFraction)) { + return `${integer}.${fraction}`; + } + + return `${integer}.${truncatedFraction}`; +} From a581c262b0ec0ccda3a33f39078790dac58ecd6e Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Tue, 30 Jan 2024 14:16:51 +0200 Subject: [PATCH 05/10] chore: simplify groupOutputsByTransactionId Signed-off-by: Eugene Panteleymonchuk --- .../transactionhistory/download/post.ts | 34 ++++--------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index 37bb3734f..4410ebea5 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -40,24 +40,16 @@ export async function post( body: ITransactionHistoryDownloadBody, ): Promise { const networkService = ServiceFactory.get("network"); - const networks = networkService.networkNames(); - ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.oneOf(request.network, networkService.networkNames(), "network"); const networkConfig = networkService.get(request.network); const nodeInfoService = ServiceFactory.get(`node-info-${request.network}`); - const tokenInfo = nodeInfoService.getNodeInfo().baseToken; - - if (networkConfig.protocolVersion !== STARDUST) { - return null; - } - - if (!networkConfig.permaNodeEndpoint) { + if (networkConfig.protocolVersion !== STARDUST || !networkConfig.permaNodeEndpoint) { return null; } const chronicleService = ServiceFactory.get(`chronicle-${networkConfig.network}`); - const outputs = await chronicleService.transactionHistoryDownload(request.address, body.targetDate); const fulfilledOutputs: OutputWithDetails[] = await Promise.all( @@ -79,9 +71,9 @@ export async function post( } return 1; }); + const tokenInfo = nodeInfoService.getNodeInfo().baseToken; - const transactionIdToOutputs = groupOutputsByTransactionId(fulfilledOutputs); - const transactions = getTransactionHistoryRecords(transactionIdToOutputs, tokenInfo); + const transactions = getTransactionHistoryRecords(groupOutputsByTransactionId(fulfilledOutputs), tokenInfo); const headers = ["Timestamp", "TransactionId", "Balance changes"]; @@ -135,24 +127,10 @@ export const groupOutputsByTransactionId = (outputsWithDetails: OutputWithDetail const transactionIdToOutputs = new Map(); for (const output of outputsWithDetails) { const detailsMetadata = output?.details?.metadata; - if (!detailsMetadata) { - // eslint-disable-next-line no-continue - continue; - } - const transactionId = output.isSpent ? detailsMetadata.transactionIdSpent : detailsMetadata.transactionId; - - if (!transactionId) { - // eslint-disable-next-line no-continue - continue; - } - - // if we don't have the transaction - const previousOutputs = transactionIdToOutputs.get(transactionId); - if (previousOutputs) { + if (detailsMetadata && transactionId) { + const previousOutputs = transactionIdToOutputs.get(transactionId) || []; transactionIdToOutputs.set(transactionId, [...previousOutputs, output]); - } else { - transactionIdToOutputs.set(transactionId, [output]); } } From e37e3a21cfe0f5f3b430d5baa23711e6823ac2bc Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Tue, 30 Jan 2024 15:07:44 +0200 Subject: [PATCH 06/10] chore: simplify calculateBalanceChange func Signed-off-by: Eugene Panteleymonchuk --- .../transactionhistory/download/post.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index 4410ebea5..7a6e11521 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -166,21 +166,24 @@ export const getTransactionHistoryRecords = ( }; export const calculateBalanceChange = (outputs: OutputWithDetails[]) => { - // eslint-disable-next-line unicorn/no-array-reduce - return outputs.reduce((acc, output) => { + let totalAmount = 0; + + for (const output of outputs) { const outputFromDetails = output?.details?.output as CommonOutput; - if (!outputFromDetails?.amount) { + // Perform the calculation only if outputFromDetails and amount are defined + if (outputFromDetails?.amount) { + let amount = Number(outputFromDetails.amount); + if (output.isSpent) { + amount *= -1; + } + totalAmount += amount; + } else { console.warn("Output details not found for:", output); - return acc; } + } - let amount = Number(outputFromDetails.amount); - if (output.isSpent) { - amount *= -1; - } - return acc + amount; - }, 0); + return totalAmount; }; /** From 63b7dea8334f830603b771db2d219f1fabaf6086 Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Wed, 31 Jan 2024 17:13:11 +0200 Subject: [PATCH 07/10] chore: update formatting function. Signed-off-by: Eugene Panteleymonchuk --- .../transactionhistory/download/post.ts | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index 7a6e11521..05d3a8853 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -158,7 +158,7 @@ export const getTransactionHistoryRecords = ( timestamp: lastOutputTime, dateFormatted: moment(lastOutputTime * 1000).format("YYYY-MM-DD HH:mm:ss"), balanceChange, - balanceChangeFormatted: (isSpent ? "-" : "+") + formatAmount(Math.abs(balanceChange), tokenInfo, false, 2, true), + balanceChangeFormatted: formatAmount(Math.abs(balanceChange), tokenInfo, false), outputs, }); } @@ -189,56 +189,45 @@ export const calculateBalanceChange = (outputs: OutputWithDetails[]) => { /** * Formats a numeric value into a string using token information and specified formatting rules. * - * @param {number} value - The value to format. - * @param {INodeInfoBaseToken} tokenInfo - Information about the token, including units and decimals. - * @param {boolean} [formatFull=false] - If true, formats the entire number. Otherwise, uses decimalPlaces. - * @param {number} [decimalPlaces=2] - Number of decimal places in the formatted output. - * @param {boolean} [trailingDecimals] - Determines inclusion of trailing zeros in decimals. - * @returns {string} The formatted amount with the token unit. + * @param value - The value to format. + * @param tokenInfo - Information about the token, including units and decimals. + * @param formatFull - If true, formats the entire number. Otherwise, uses decimalPlaces. + * @param isSpent + * @returns The formatted amount with the token unit. */ -export function formatAmount( - value: number, - tokenInfo: INodeInfoBaseToken, - formatFull: boolean = false, - decimalPlaces: number = 2, - trailingDecimals?: boolean, -): string { +export function formatAmount(value: number, tokenInfo: INodeInfoBaseToken, formatFull: boolean = false, isSpent: boolean = false): string { + const isSpentSymbol = isSpent ? "-" : "+"; + if (formatFull) { - return `${value} ${tokenInfo.subunit ?? tokenInfo.unit}`; + return `${isSpentSymbol}${value} ${tokenInfo.subunit ?? tokenInfo.unit}`; } const baseTokenValue = value / Math.pow(10, tokenInfo.decimals); - const formattedAmount = toFixedNoRound(baseTokenValue, decimalPlaces, trailingDecimals); + const formattedAmount = cropNumber(baseTokenValue); - return `${formattedAmount} ${tokenInfo.unit}`; + return `${isSpentSymbol}${formattedAmount} ${tokenInfo.unit}`; } /** - * Format amount to two decimal places without rounding off. - * @param value The raw amount to format. - * @param precision The decimal places to show. - * @param trailingDecimals Whether to show trailing decimals. - * @returns The formatted amount. + * Crops the fractional part of a number to 6 digits. + * @param value The value to crop. + * @param decimalPlaces - The number of decimal places to include in the formatted output. + * @returns The cropped value. */ -function toFixedNoRound(value: number, precision: number = 2, trailingDecimals?: boolean): string { - const defaultDecimals = "0".repeat(precision); - const valueString = `${value}`; - const [integer, fraction = defaultDecimals] = valueString.split("."); - - if (fraction === defaultDecimals && !trailingDecimals) { - return valueString; - } +function cropNumber(value: number, decimalPlaces: number = 6): string { + const valueAsString = value.toString(); - if (!precision) { - return integer; + if (!valueAsString.includes(".")) { + return valueAsString; } - const truncatedFraction = fraction.slice(0, precision); + const [integerPart, rawFractionalPart] = valueAsString.split("."); + let fractionalPart = rawFractionalPart; - // avoid 0.00 case - if (!Number(truncatedFraction)) { - return `${integer}.${fraction}`; + if (fractionalPart.length > decimalPlaces) { + fractionalPart = fractionalPart.slice(0, 6); } + fractionalPart = fractionalPart.replace(/0+$/, ""); - return `${integer}.${truncatedFraction}`; + return fractionalPart ? `${integerPart}.${fractionalPart}` : integerPart; } From 8858c6979ad62b9e67ad70673119986f91574609 Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Wed, 31 Jan 2024 17:19:54 +0200 Subject: [PATCH 08/10] fix: eslint, symbol if value zero. Signed-off-by: Eugene Panteleymonchuk --- .../stardust/transactionhistory/download/post.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index 05d3a8853..b9c7dc9ef 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -158,7 +158,7 @@ export const getTransactionHistoryRecords = ( timestamp: lastOutputTime, dateFormatted: moment(lastOutputTime * 1000).format("YYYY-MM-DD HH:mm:ss"), balanceChange, - balanceChangeFormatted: formatAmount(Math.abs(balanceChange), tokenInfo, false), + balanceChangeFormatted: formatAmount(Math.abs(balanceChange), tokenInfo, false, isSpent), outputs, }); } @@ -192,11 +192,16 @@ export const calculateBalanceChange = (outputs: OutputWithDetails[]) => { * @param value - The value to format. * @param tokenInfo - Information about the token, including units and decimals. * @param formatFull - If true, formats the entire number. Otherwise, uses decimalPlaces. - * @param isSpent + * @param isSpent - boolean indicating if the amount is spent or not. Need to provide right symbol. * @returns The formatted amount with the token unit. */ export function formatAmount(value: number, tokenInfo: INodeInfoBaseToken, formatFull: boolean = false, isSpent: boolean = false): string { - const isSpentSymbol = isSpent ? "-" : "+"; + let isSpentSymbol = isSpent ? "-" : "+"; + + if (!value) { + // 0 is not spent + isSpentSymbol = ""; + } if (formatFull) { return `${isSpentSymbol}${value} ${tokenInfo.subunit ?? tokenInfo.unit}`; From b632ebf8bc4a4ecc95d1d02b572201c6bb1313ae Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Wed, 31 Jan 2024 17:25:19 +0200 Subject: [PATCH 09/10] fix: eslint Signed-off-by: Eugene Panteleymonchuk --- api/src/routes/stardust/transactionhistory/download/post.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index b9c7dc9ef..355a9a7c8 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -118,7 +118,7 @@ const requestOutputDetails = async (outputId: string, network: string): Promise< } return null; } catch { - console.log("Failed loading transaction history details!"); + console.warn("Failed loading transaction history details!"); return null; } }; From f3d9e5438f41996bc50217c0fd44ef9e00352548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bego=C3=B1a=20Alvarez?= Date: Wed, 31 Jan 2024 17:12:12 +0100 Subject: [PATCH 10/10] enhancement: rephrase texts --- client/src/app/components/stardust/DownloadModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/app/components/stardust/DownloadModal.tsx b/client/src/app/components/stardust/DownloadModal.tsx index 139e44657..a42f87c2e 100644 --- a/client/src/app/components/stardust/DownloadModal.tsx +++ b/client/src/app/components/stardust/DownloadModal.tsx @@ -12,7 +12,7 @@ interface DownloadModalProps { readonly address: string; } -const DOWNLOAD_INFO = "History will be downloaded from present date up to target date."; +const DOWNLOAD_INFO = "History will be downloaded from start date to present."; const DownloadModal: React.FC = ({ network, address }) => { const [showModal, setShowModal] = useState(false); @@ -58,7 +58,7 @@ const DownloadModal: React.FC = ({ network, address }) => {
-
Select target date
+
Select start date
info