Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Improve Address Transaction History export (#853) #911

Merged
merged 14 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 188 additions & 9 deletions api/src/routes/stardust/transactionhistory/download/post.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
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 { IDataResponse } from "../../../../models/api/IDataResponse";
import { ITransactionHistoryDownloadBody } from "../../../../models/api/stardust/chronicle/ITransactionHistoryDownloadBody";
import { ITransactionHistoryRequest } from "../../../../models/api/stardust/chronicle/ITransactionHistoryRequest";
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 { 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 };

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.
Expand All @@ -23,28 +40,55 @@ export async function post(
body: ITransactionHistoryDownloadBody,
): Promise<IDataResponse | null> {
const networkService = ServiceFactory.get<NetworkService>("network");
const networks = networkService.networkNames();
ValidationHelper.oneOf(request.network, networks, "network");
ValidationHelper.oneOf(request.network, networkService.networkNames(), "network");

const networkConfig = networkService.get(request.network);

if (networkConfig.protocolVersion !== STARDUST) {
return null;
}

if (!networkConfig.permaNodeEndpoint) {
const nodeInfoService = ServiceFactory.get<NodeInfoService>(`node-info-${request.network}`);
if (networkConfig.protocolVersion !== STARDUST || !networkConfig.permaNodeEndpoint) {
return null;
}

const chronicleService = ServiceFactory.get<ChronicleService>(`chronicle-${networkConfig.network}`);
const outputs = await chronicleService.transactionHistoryDownload(request.address, body.targetDate);

const fulfilledOutputs: OutputWithDetails[] = await Promise.all(
outputs.items.map(async (output) => {
const details = await requestOutputDetails(output.outputId, networkConfig.network);
return {
...output,
details,
amount: details?.output?.amount,
};
}),
);

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 tokenInfo = nodeInfoService.getNodeInfo().baseToken;

const result = await chronicleService.transactionHistoryDownload(request.address, body.targetDate);
const transactions = getTransactionHistoryRecords(groupOutputsByTransactionId(fulfilledOutputs), 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.json", JSON.stringify(result));
jsZip.file("history.csv", csvContent);
const content = await jsZip.generateAsync({ type: "nodebuffer" });

response = {
Expand All @@ -57,3 +101,138 @@ export async function post(

return response;
}

const requestOutputDetails = async (outputId: string, network: string): Promise<OutputResponse | null> => {
if (!outputId) {
return null;
}

const apiService = ServiceFactory.get<StardustApiService>(`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.warn("Failed loading transaction history details!");
return null;
}
};

export const groupOutputsByTransactionId = (outputsWithDetails: OutputWithDetails[]) => {
const transactionIdToOutputs = new Map<string, OutputWithDetails[]>();
for (const output of outputsWithDetails) {
const detailsMetadata = output?.details?.metadata;
const transactionId = output.isSpent ? detailsMetadata.transactionIdSpent : detailsMetadata.transactionId;
if (detailsMetadata && transactionId) {
const previousOutputs = transactionIdToOutputs.get(transactionId) || [];
transactionIdToOutputs.set(transactionId, [...previousOutputs, output]);
}
}

return transactionIdToOutputs;
};

export const getTransactionHistoryRecords = (
transactionIdToOutputs: Map<string, OutputWithDetails[]>,
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 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"),
balanceChange,
balanceChangeFormatted: formatAmount(Math.abs(balanceChange), tokenInfo, false, isSpent),
outputs,
});
}
return calculatedTransactions;
};

export const calculateBalanceChange = (outputs: OutputWithDetails[]) => {
let totalAmount = 0;

for (const output of outputs) {
const outputFromDetails = output?.details?.output as CommonOutput;

// 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 totalAmount;
};

/**
* Formats a numeric value into a string using token information and specified formatting rules.
*
* @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 - 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 {
let isSpentSymbol = isSpent ? "-" : "+";

if (!value) {
// 0 is not spent
isSpentSymbol = "";
}

if (formatFull) {
return `${isSpentSymbol}${value} ${tokenInfo.subunit ?? tokenInfo.unit}`;
}

const baseTokenValue = value / Math.pow(10, tokenInfo.decimals);
const formattedAmount = cropNumber(baseTokenValue);

return `${isSpentSymbol}${formattedAmount} ${tokenInfo.unit}`;
}

/**
* 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 cropNumber(value: number, decimalPlaces: number = 6): string {
const valueAsString = value.toString();

if (!valueAsString.includes(".")) {
return valueAsString;
}

const [integerPart, rawFractionalPart] = valueAsString.split(".");
let fractionalPart = rawFractionalPart;

if (fractionalPart.length > decimalPlaces) {
fractionalPart = fractionalPart.slice(0, 6);
}
fractionalPart = fractionalPart.replace(/0+$/, "");

return fractionalPart ? `${integerPart}.${fractionalPart}` : integerPart;
}
4 changes: 2 additions & 2 deletions client/src/app/components/stardust/DownloadModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DownloadModalProps> = ({ network, address }) => {
const [showModal, setShowModal] = useState(false);
Expand Down Expand Up @@ -58,7 +58,7 @@ const DownloadModal: React.FC<DownloadModalProps> = ({ network, address }) => {
<div className="modal--body">
<div className="input-container">
<div className="row middle">
<div className="date-label">Select target date</div>
<div className="date-label">Select start date</div>
<Tooltip tooltipContent={DOWNLOAD_INFO}>
<div className="modal--icon">
<span className="material-icons">info</span>
Expand Down
Loading