From 564ea92ea8fd649f964a198292181aa7581188d9 Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 26 Mar 2024 15:54:27 +0100 Subject: [PATCH] Feat: Add burned mana to slot (#1317) * feat: Add novaTimeService for convenience and use it. * feat: Add route (and influx support) to get slot burned mana. Add client hook and use it on slot page. * chore: Silence eslint 'unresolved' on CI (api) * feat: Cache last 20 ManaBurned requests fetched on api * fix: Fix formatAmount for '0' values. Add tests for it. * feat: Ensure mana burned is shown on slot page * feat: Add burned mana to Slot feed on Landing * fix: Fix field name in novaTimeService Co-authored-by: JCNoguera <88061365+VmMad@users.noreply.github.com> --------- Co-authored-by: JCNoguera <88061365+VmMad@users.noreply.github.com> Co-authored-by: Branko Bosnic --- api/src/initServices.ts | 31 +++-- .../nova/stats/slot/ISlotManaBurnedRequest.ts | 11 ++ .../stats/slot/ISlotManaBurnedResponse.ts | 6 + api/src/models/influx/nova/IInfluxDbCache.ts | 12 +- api/src/routes.ts | 1 + api/src/routes/nova/epoch/influx/get.ts | 5 +- api/src/routes/nova/slot/mana-burned/get.ts | 45 ++++++++ .../services/nova/influx/influxServiceNova.ts | 107 +++++++++++++++--- api/src/services/nova/novaTimeService.ts | 71 ++++++++++++ .../stardust/influx/influxServiceStardust.ts | 2 +- .../components/nova/landing/SlotTableCell.tsx | 26 ++++- client/src/app/routes/nova/SlotPage.tsx | 5 +- .../nova/hooks/useGenerateSlotsTable.ts | 8 +- .../helpers/nova/hooks/useSlotManaBurned.ts | 48 ++++++++ .../stardust/valueFormatHelper.spec.ts | 14 ++- .../helpers/stardust/valueFormatHelper.tsx | 4 + .../api/nova/stats/ISlotManaBurnedRequest.ts | 11 ++ .../api/nova/stats/ISlotManaBurnedResponse.ts | 6 + client/src/services/nova/novaApiClient.ts | 11 ++ 19 files changed, 381 insertions(+), 43 deletions(-) create mode 100644 api/src/models/api/nova/stats/slot/ISlotManaBurnedRequest.ts create mode 100644 api/src/models/api/nova/stats/slot/ISlotManaBurnedResponse.ts create mode 100644 api/src/routes/nova/slot/mana-burned/get.ts create mode 100644 api/src/services/nova/novaTimeService.ts create mode 100644 client/src/helpers/nova/hooks/useSlotManaBurned.ts create mode 100644 client/src/models/api/nova/stats/ISlotManaBurnedRequest.ts create mode 100644 client/src/models/api/nova/stats/ISlotManaBurnedResponse.ts diff --git a/api/src/initServices.ts b/api/src/initServices.ts index 90e0a270e..68f3613f0 100644 --- a/api/src/initServices.ts +++ b/api/src/initServices.ts @@ -27,6 +27,7 @@ import { NovaFeed } from "./services/nova/feed/novaFeed"; import { InfluxServiceNova } from "./services/nova/influx/influxServiceNova"; import { NodeInfoService as NodeInfoServiceNova } from "./services/nova/nodeInfoService"; import { NovaApiService } from "./services/nova/novaApiService"; +import { NovaTimeService } from "./services/nova/novaTimeService"; import { NovaStatsService } from "./services/nova/stats/novaStatsService"; import { ValidatorService } from "./services/nova/validatorService"; import { ChronicleService as ChronicleServiceStardust } from "./services/stardust/chronicleService"; @@ -246,21 +247,29 @@ function initNovaServices(networkConfig: INetwork): void { ServiceFactory.register(`feed-${networkConfig.network}`, () => novaFeed); }); + NovaTimeService.build(novaClient) + .then((novaTimeService) => { + ServiceFactory.register(`nova-time-${networkConfig.network}`, () => novaTimeService); + + const influxDBService = new InfluxServiceNova(networkConfig); + influxDBService + .buildClient() + .then((hasClient) => { + logger.debug(`[InfluxDb] Registering client with name "${networkConfig.network}". Has client: ${hasClient}`); + if (hasClient) { + ServiceFactory.register(`influxdb-${networkConfig.network}`, () => influxDBService); + } + }) + .catch((e) => logger.error(`Failed to build influxDb client for "${networkConfig.network}". Cause: ${e}`)); + }) + .catch((err) => { + logger.error(`Failed building [novaTimeService]. Cause: ${err}`); + }); + const validatorService = new ValidatorService(networkConfig); validatorService.setupValidatorsCollection(); ServiceFactory.register(`validator-service-${networkConfig.network}`, () => validatorService); }); - - const influxDBService = new InfluxServiceNova(networkConfig); - influxDBService - .buildClient() - .then((hasClient) => { - logger.debug(`[InfluxDb] Registering client with name "${networkConfig.network}". Has client: ${hasClient}`); - if (hasClient) { - ServiceFactory.register(`influxdb-${networkConfig.network}`, () => influxDBService); - } - }) - .catch((e) => logger.warn(`Failed to build influxDb client for "${networkConfig.network}". Cause: ${e}`)); } /** diff --git a/api/src/models/api/nova/stats/slot/ISlotManaBurnedRequest.ts b/api/src/models/api/nova/stats/slot/ISlotManaBurnedRequest.ts new file mode 100644 index 000000000..b7002e9ac --- /dev/null +++ b/api/src/models/api/nova/stats/slot/ISlotManaBurnedRequest.ts @@ -0,0 +1,11 @@ +export interface ISlotManaBurnedRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The slot index to get the mana burned for. + */ + slotIndex: string; +} diff --git a/api/src/models/api/nova/stats/slot/ISlotManaBurnedResponse.ts b/api/src/models/api/nova/stats/slot/ISlotManaBurnedResponse.ts new file mode 100644 index 000000000..0b465e26a --- /dev/null +++ b/api/src/models/api/nova/stats/slot/ISlotManaBurnedResponse.ts @@ -0,0 +1,6 @@ +import { IResponse } from "../../IResponse"; + +export interface ISlotManaBurnedResponse extends IResponse { + slotIndex?: number; + manaBurned?: number; +} diff --git a/api/src/models/influx/nova/IInfluxDbCache.ts b/api/src/models/influx/nova/IInfluxDbCache.ts index 6b397266c..1d65c68ef 100644 --- a/api/src/models/influx/nova/IInfluxDbCache.ts +++ b/api/src/models/influx/nova/IInfluxDbCache.ts @@ -22,7 +22,7 @@ import { IUnlockConditionsPerTypeDailyInflux, IValidatorsActivityDailyInflux, } from "./IInfluxTimedEntries"; -import { DayKey } from "../types"; +import { DayKey, ITimedEntry } from "../types"; /** * The cache for influx graphs (daily). @@ -79,6 +79,16 @@ interface IEpochAnalyticStats { */ export type IInfluxEpochAnalyticsCache = Map; +export type ManaBurnedInSlot = ITimedEntry & { + slotIndex: number; + manaBurned: number; +}; + +/** + * The epoch stats cache. Map epoch index to stats. + */ +export type ManaBurnedInSlotCache = Map; + /** * The helper to initialize empty maps * @returns The initial cache object diff --git a/api/src/routes.ts b/api/src/routes.ts index 842db5157..eb4b40362 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -303,6 +303,7 @@ export const routes: IRoute[] = [ }, { path: "/nova/slot/:network/:slotIndex", method: "get", folder: "nova/slot", func: "get" }, { path: "/nova/epoch/committee/:network/:epochIndex", method: "get", folder: "nova/epoch/committee", func: "get" }, + { path: "/nova/slot/mana-burned/:network/:slotIndex", method: "get", folder: "nova/slot/mana-burned", func: "get" }, { path: "/nova/analytics/:network", method: "get", diff --git a/api/src/routes/nova/epoch/influx/get.ts b/api/src/routes/nova/epoch/influx/get.ts index 69ae8ff80..5287eb67f 100644 --- a/api/src/routes/nova/epoch/influx/get.ts +++ b/api/src/routes/nova/epoch/influx/get.ts @@ -41,10 +41,7 @@ export async function get(_: IConfiguration, request: IEpochAnalyticStatsRequest } const epochIndex = Number.parseInt(request.epochIndex, 10); - let maybeEpochStats = influxService.getEpochAnalyticStats(epochIndex); - if (!maybeEpochStats) { - maybeEpochStats = await influxService.fetchAnalyticsForEpoch(epochIndex, protocolParameters); - } + const maybeEpochStats = await influxService.getEpochAnalyticStats(epochIndex); return maybeEpochStats ? { diff --git a/api/src/routes/nova/slot/mana-burned/get.ts b/api/src/routes/nova/slot/mana-burned/get.ts new file mode 100644 index 000000000..c12f7658c --- /dev/null +++ b/api/src/routes/nova/slot/mana-burned/get.ts @@ -0,0 +1,45 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { ISlotManaBurnedRequest } from "../../../../models/api/nova/stats/slot/ISlotManaBurnedRequest"; +import { ISlotManaBurnedResponse } from "../../../../models/api/nova/stats/slot/ISlotManaBurnedResponse"; +import { IConfiguration } from "../../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../../models/db/protocolVersion"; +import { NetworkService } from "../../../../services/networkService"; +import { InfluxServiceNova } from "../../../../services/nova/influx/influxServiceNova"; +import { ValidationHelper } from "../../../../utils/validationHelper"; + +/** + * Fetch the slot mana burned from influx nova. + * @param _ The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(_: IConfiguration, request: ISlotManaBurnedRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.numberFromString(request.slotIndex, "slotIndex"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const influxService = ServiceFactory.get(`influxdb-${networkConfig.network}`); + + if (influxService) { + const slotIndex = Number.parseInt(request.slotIndex, 10); + const manaBurnedInSlot = await influxService.getManaBurnedBySlotIndex(slotIndex); + + if (manaBurnedInSlot) { + return { + slotIndex: manaBurnedInSlot.slotIndex, + manaBurned: manaBurnedInSlot.manaBurned, + }; + } + + return { error: `Could not fetch mana burned for slot ${request.slotIndex}` }; + } + + return { error: "Influx service not found for this network." }; +} diff --git a/api/src/services/nova/influx/influxServiceNova.ts b/api/src/services/nova/influx/influxServiceNova.ts index 884b39a8b..8d0b6e4e1 100644 --- a/api/src/services/nova/influx/influxServiceNova.ts +++ b/api/src/services/nova/influx/influxServiceNova.ts @@ -1,5 +1,4 @@ /* eslint-disable import/no-unresolved */ -import { ProtocolParameters } from "@iota/sdk-nova"; import { InfluxDB, toNanoDate } from "influx"; import moment from "moment"; import cron from "node-cron"; @@ -35,13 +34,14 @@ import { } from "./influxQueries"; import { ServiceFactory } from "../../../factories/serviceFactory"; import logger from "../../../logger"; -import { IEpochAnalyticStats } from "../../../models/api/nova/stats/epoch/IEpochAnalyticStats"; import { INetwork } from "../../../models/db/INetwork"; import { IInfluxAnalyticsCache, IInfluxDailyCache, IInfluxEpochAnalyticsCache, initializeEmptyDailyCache, + ManaBurnedInSlot, + ManaBurnedInSlotCache, } from "../../../models/influx/nova/IInfluxDbCache"; import { IAccountActivityDailyInflux, @@ -68,9 +68,8 @@ import { IValidatorsActivityDailyInflux, } from "../../../models/influx/nova/IInfluxTimedEntries"; import { ITimedEntry } from "../../../models/influx/types"; -import { epochIndexToUnixTimeRangeConverter, unixTimestampToEpochIndexConverter } from "../../../utils/nova/novaTimeUtils"; import { InfluxDbClient, NANOSECONDS_IN_MILLISECOND } from "../../influx/influxClient"; -import { NodeInfoService } from "../nodeInfoService"; +import { NovaTimeService } from "../novaTimeService"; type EpochUpdate = ITimedEntry & { epochIndex: number; @@ -84,6 +83,12 @@ type EpochUpdate = ITimedEntry & { * Epoch analyitics cache MAX size. */ const EPOCH_CACHE_MAX = 20; + +/** + * Epoch analyitics cache MAX size. + */ +const MANA_BURNED_CACHE_MAX = 20; + /** * The collect graph data interval cron expression. * Every hour at 59 min 55 sec @@ -123,15 +128,27 @@ export class InfluxServiceNova extends InfluxDbClient { */ protected readonly _analyticsCache: IInfluxAnalyticsCache; + /** + * The current influx mana burned in slot cache. + */ + protected readonly _manaBurnedInSlotCache: ManaBurnedInSlotCache; + /** * The network in context for this client. */ protected readonly _network: INetwork; + /** + * Nova time service for conversions. + */ + private readonly _novatimeService: NovaTimeService; + constructor(network: INetwork) { super(network); + this._novatimeService = ServiceFactory.get(`nova-time-${network.network}`); this._dailyCache = initializeEmptyDailyCache(); this._epochCache = new Map(); + this._manaBurnedInSlotCache = new Map(); this._analyticsCache = {}; } @@ -243,13 +260,45 @@ export class InfluxServiceNova extends InfluxDbClient { return this._analyticsCache.delegatorsCount; } - public getEpochAnalyticStats(epochIndex: number): IEpochAnalyticStats | undefined { + public async getEpochAnalyticStats(epochIndex: number) { + if (!this._epochCache.get(epochIndex)) { + await this.collectEpochStatsByIndex(epochIndex); + } + return this._epochCache.get(epochIndex); } - public async fetchAnalyticsForEpoch(epochIndex: number, parameters: ProtocolParameters) { - await this.collectEpochStatsByIndex(epochIndex, parameters); - return this._epochCache.get(epochIndex); + /** + * Get the manaBurned stats by slot index. + * @param slotIndex - The slot index. + */ + public async getManaBurnedBySlotIndex(slotIndex: number): Promise { + if (this._manaBurnedInSlotCache.has(slotIndex)) { + return this._manaBurnedInSlotCache.get(slotIndex); + } + + const { from, to } = this._novatimeService.getSlotIndexToUnixTimeRange(slotIndex); + const fromNano = toNanoDate((moment(Number(from) * 1000).valueOf() * NANOSECONDS_IN_MILLISECOND).toString()); + const toNano = toNanoDate((moment(Number(to) * 1000).valueOf() * NANOSECONDS_IN_MILLISECOND).toString()); + + let manaBurnedResult: ManaBurnedInSlot | null = null; + + await this.queryInflux(MANA_BURN_DAILY_QUERY.partial, fromNano, toNano) + .then((results) => { + for (const update of results) { + if (update.manaBurned !== undefined) { + update.slotIndex = slotIndex; + this.updateBurnedManaCache(update); + + manaBurnedResult = update; + } + } + }) + .catch((e) => { + logger.warn(`[InfluxClient] Query 'mana burned in slot' failed for (${this._network.network}). Cause ${e}`); + }); + + return manaBurnedResult; } protected setupDataCollection() { @@ -429,12 +478,10 @@ export class InfluxServiceNova extends InfluxDbClient { /** * Get the epoch analytics by index and set it in the cache. * @param epochIndex - The epoch index. - * @param parameters - The protocol parameters information. */ - private async collectEpochStatsByIndex(epochIndex: number, parameters: ProtocolParameters) { + private async collectEpochStatsByIndex(epochIndex: number) { try { - const epochIndexToUnixTimeRange = epochIndexToUnixTimeRangeConverter(parameters); - const { from, to } = epochIndexToUnixTimeRange(epochIndex); + const { from, to } = this._novatimeService.getEpochIndexToUnixTimeRange(epochIndex); const fromNano = toNanoDate((moment(Number(from) * 1000).valueOf() * NANOSECONDS_IN_MILLISECOND).toString()); const toNano = toNanoDate((moment(Number(to) * 1000).valueOf() * NANOSECONDS_IN_MILLISECOND).toString()); @@ -461,12 +508,9 @@ export class InfluxServiceNova extends InfluxDbClient { private async collectEpochStats() { try { logger.debug(`[InfluxNova] Collecting epoch stats for "${this._network.network}"`); - const nodeService = ServiceFactory.get(`node-info-${this._network.network}`); - const parameters = await nodeService.getProtocolParameters(); - const unixTimestampToEpochIndex = unixTimestampToEpochIndexConverter(parameters); - const epochIndex = unixTimestampToEpochIndex(moment().unix()); + const epochIndex = this._novatimeService.getUnixTimestampToEpochIndex(moment().unix()); // eslint-disable-next-line no-void - void this.collectEpochStatsByIndex(epochIndex, parameters); + void this.collectEpochStatsByIndex(epochIndex); } catch (err) { logger.warn(`[InfluxNova] Failed refreshing epoch stats for "${this._network.network}". Cause: ${err}`); } @@ -496,7 +540,34 @@ export class InfluxServiceNova extends InfluxDbClient { lowestIndex = index; } - if (epochIndex < lowestIndex) { + if (index < lowestIndex) { + lowestIndex = index; + } + } + + logger.debug(`[InfluxNova] Deleting epoch index "${lowestIndex}" ("${this._network.network}")`); + + this._epochCache.delete(lowestIndex); + } + } + } + + private updateBurnedManaCache(update: ManaBurnedInSlot) { + if (update.slotIndex && !this._manaBurnedInSlotCache.has(update.slotIndex)) { + const { slotIndex } = update; + + this._manaBurnedInSlotCache.set(slotIndex, update); + + logger.debug(`[InfluxNova] Added slot index "${slotIndex}" to ManaBurned cache for "${this._network.network}"`); + + if (this._manaBurnedInSlotCache.size > MANA_BURNED_CACHE_MAX) { + let lowestIndex: number; + for (const index of this._manaBurnedInSlotCache.keys()) { + if (!lowestIndex) { + lowestIndex = index; + } + + if (index < lowestIndex) { lowestIndex = index; } } diff --git a/api/src/services/nova/novaTimeService.ts b/api/src/services/nova/novaTimeService.ts new file mode 100644 index 000000000..194b24e28 --- /dev/null +++ b/api/src/services/nova/novaTimeService.ts @@ -0,0 +1,71 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { Client, ProtocolParameters } from "@iota/sdk-nova"; +import { + epochIndexToSlotIndexRangeConverter, + epochIndexToUnixTimeRangeConverter, + slotIndexToEpochIndexConverter, + slotIndexToUnixTimeRangeConverter, + unixTimestampToEpochIndexConverter, + unixTimestampToSlotIndexConverter, +} from "../../utils/nova/novaTimeUtils"; + +export class NovaTimeService { + private readonly protocolParameters: ProtocolParameters; + + private readonly unixTimestampToSlotIndex: (unixTimestampSeconds: number) => number; + + private readonly slotIndexToUnixTimeRange: (targetSlotIndex: number) => { from: number; to: number }; + + private readonly slotIndexToEpochIndex: (targetSlotIndex: number) => number; + + private readonly unixTimestampToEpochIndex: (unixTimestampSeconds: number) => number; + + private readonly epochIndexToSlotIndexRange: (targetEpochIndex: number) => { from: number; to: number }; + + private readonly epochIndexToUnixTimeRange: (targetEpochIndex: number) => { from: number; to: number }; + + constructor(protocolParameters: ProtocolParameters) { + this.protocolParameters = protocolParameters; + + this.unixTimestampToSlotIndex = unixTimestampToSlotIndexConverter(protocolParameters); + this.slotIndexToUnixTimeRange = slotIndexToUnixTimeRangeConverter(protocolParameters); + this.slotIndexToEpochIndex = slotIndexToEpochIndexConverter(protocolParameters); + this.unixTimestampToEpochIndex = unixTimestampToEpochIndexConverter(protocolParameters); + this.epochIndexToSlotIndexRange = epochIndexToSlotIndexRangeConverter(protocolParameters); + this.epochIndexToUnixTimeRange = epochIndexToUnixTimeRangeConverter(protocolParameters); + } + + public get protocolParams(): ProtocolParameters { + return this.protocolParameters; + } + + public static async build(client: Client) { + const protocolParameters = await client.getProtocolParameters(); + return new NovaTimeService(protocolParameters); + } + + public getUnixTimestampToSlotIndex(unixTimestampSeconds: number): number { + return this.unixTimestampToSlotIndex(unixTimestampSeconds); + } + + public getSlotIndexToUnixTimeRange(targetSlotIndex: number): { from: number; to: number } { + return this.slotIndexToUnixTimeRange(targetSlotIndex); + } + + public getSlotIndexToEpochIndex(targetSlotIndex: number): number { + return this.slotIndexToEpochIndex(targetSlotIndex); + } + + public getUnixTimestampToEpochIndex(unixTimestampSeconds: number): number { + return this.unixTimestampToEpochIndex(unixTimestampSeconds); + } + + public getEpochIndexToSlotIndexRange(targetEpochIndex: number): { from: number; to: number } { + return this.epochIndexToSlotIndexRange(targetEpochIndex); + } + + public getEpochIndexToUnixTimeRange(targetEpochIndex: number): { from: number; to: number } { + return this.epochIndexToUnixTimeRange(targetEpochIndex); + } +} diff --git a/api/src/services/stardust/influx/influxServiceStardust.ts b/api/src/services/stardust/influx/influxServiceStardust.ts index 7746c78af..2b9cfb248 100644 --- a/api/src/services/stardust/influx/influxServiceStardust.ts +++ b/api/src/services/stardust/influx/influxServiceStardust.ts @@ -448,7 +448,7 @@ export class InfluxServiceStardust extends InfluxDbClient { lowestIndex = index; } - if (milestoneIndex < lowestIndex) { + if (index < lowestIndex) { lowestIndex = index; } } diff --git a/client/src/app/components/nova/landing/SlotTableCell.tsx b/client/src/app/components/nova/landing/SlotTableCell.tsx index d18d2ba28..a12a2e2d8 100644 --- a/client/src/app/components/nova/landing/SlotTableCell.tsx +++ b/client/src/app/components/nova/landing/SlotTableCell.tsx @@ -4,16 +4,19 @@ import React from "react"; import StatusPill from "../StatusPill"; import TruncatedId from "../../stardust/TruncatedId"; import classNames from "classnames"; +import { useSlotManaBurned } from "~/helpers/nova/hooks/useSlotManaBurned"; +import Spinner from "../../Spinner"; export enum SlotTableCellType { StatusPill = "status-pill", Link = "link", Text = "text", TruncatedId = "truncated-id", + BurnedMana = "burned-mana", Empty = "empty", } -export type TSlotTableData = IPillStatusCell | ITextCell | ILinkCell | ITruncatedIdCell | IEmptyCell; +export type TSlotTableData = IPillStatusCell | ITextCell | ILinkCell | ITruncatedIdCell | IBurnedManaCell | IEmptyCell; export default function SlotTableCellWrapper(cellData: TSlotTableData): React.JSX.Element { let Component: React.JSX.Element; @@ -31,6 +34,9 @@ export default function SlotTableCellWrapper(cellData: TSlotTableData): React.JS case SlotTableCellType.Empty: Component = ; break; + case SlotTableCellType.BurnedMana: + Component = ; + break; default: { Component = ; break; @@ -93,10 +99,26 @@ function TruncatedIdCell({ data, href }: ITruncatedIdCell): React.JSX.Element { ); } +interface IBurnedManaCell { + type: SlotTableCellType.BurnedMana; + data: string; + shouldLoad: boolean; +} + +function BurnedManaCell({ data, shouldLoad }: IBurnedManaCell): React.JSX.Element { + if (!shouldLoad) { + return ; + } + + const { slotManaBurned, isLoading } = useSlotManaBurned(data); + + return {isLoading ? : slotManaBurned?.manaBurned ?? "--"}; +} + interface IEmptyCell { type: SlotTableCellType.Empty; } -function EmptyCell({ type }: IEmptyCell): React.JSX.Element { +function EmptyCell(_: IEmptyCell): React.JSX.Element { return
; } diff --git a/client/src/app/routes/nova/SlotPage.tsx b/client/src/app/routes/nova/SlotPage.tsx index 97d6f1250..85befd173 100644 --- a/client/src/app/routes/nova/SlotPage.tsx +++ b/client/src/app/routes/nova/SlotPage.tsx @@ -11,6 +11,7 @@ import { getSlotStatusFromLatestSlotCommitments, parseSlotIndexFromParams } from import { SLOT_STATUS_TO_PILL_STATUS } from "~/app/lib/constants/slot.constants"; import useSlotBlocks from "~/helpers/nova/hooks/useSlotBlocks"; import SlotBlocksSection from "~/app/components/nova/slot/blocks/SlotBlocksSection"; +import { useSlotManaBurned } from "~/helpers/nova/hooks/useSlotManaBurned"; import "./SlotPage.scss"; export default function SlotPage({ @@ -23,6 +24,7 @@ export default function SlotPage({ }>): React.JSX.Element { const { latestSlotCommitments = [] } = useSlotsFeed(); const { slotCommitment: slotCommitmentDetails } = useSlotDetails(network, slotIndex); + const { slotManaBurned } = useSlotManaBurned(slotIndex); const parsedSlotIndex = parseSlotIndexFromParams(slotIndex); const slotStatus = getSlotStatusFromLatestSlotCommitments(parsedSlotIndex, latestSlotCommitments); @@ -41,6 +43,7 @@ export default function SlotPage({ slotCommitmentDetails?.referenceManaCost?.toString() ?? "-", }, + { label: "Mana burned", value: slotManaBurned?.manaBurned ?? "-" }, ]; return ( @@ -62,7 +65,7 @@ export default function SlotPage({ {dataRows.map((dataRow, index) => { - if (dataRow.value || dataRow.truncatedId) { + if (dataRow.value !== undefined || dataRow.truncatedId) { return ; } })} diff --git a/client/src/helpers/nova/hooks/useGenerateSlotsTable.ts b/client/src/helpers/nova/hooks/useGenerateSlotsTable.ts index dcaf3e03f..399a60d34 100644 --- a/client/src/helpers/nova/hooks/useGenerateSlotsTable.ts +++ b/client/src/helpers/nova/hooks/useGenerateSlotsTable.ts @@ -1,4 +1,4 @@ -import type { ISlotCommitmentWrapper } from "~/models/api/nova/ILatestSlotCommitmentsResponse"; +import { ISlotCommitmentWrapper, SlotCommitmentStatus } from "~/models/api/nova/ILatestSlotCommitmentsResponse"; import type { ITableRow } from "~/app/components/Table"; import { SlotTableCellType, type TSlotTableData } from "~/app/components/nova/landing/SlotTableCell"; import { SlotStatus } from "~app/lib/enums"; @@ -104,7 +104,6 @@ function getSlotCommitmentTableRow( const referenceManaCost = commitmentWrapper.slotCommitment.referenceManaCost.toString(); const blocks = "2000"; const transactions = "2000"; - const burnedMana = "200000"; const slotStatus = commitmentWrapper.status; Object.values(SlotTableHeadings).forEach((heading) => { @@ -143,8 +142,9 @@ function getSlotCommitmentTableRow( break; case SlotTableHeadings.BurnedMana: tableData = { - type: SlotTableCellType.Text, - data: burnedMana, + type: SlotTableCellType.BurnedMana, + shouldLoad: slotStatus === SlotCommitmentStatus.Finalized, + data: slotIndex.toString(), }; break; case SlotTableHeadings.Status: diff --git a/client/src/helpers/nova/hooks/useSlotManaBurned.ts b/client/src/helpers/nova/hooks/useSlotManaBurned.ts new file mode 100644 index 000000000..cff4001d9 --- /dev/null +++ b/client/src/helpers/nova/hooks/useSlotManaBurned.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import { ServiceFactory } from "~factories/serviceFactory"; +import { NOVA } from "~models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; +import { useNetworkInfoNova } from "../networkInfo"; +import { ISlotManaBurnedResponse } from "~/models/api/nova/stats/ISlotManaBurnedResponse"; + +/** + * Fetch the slot mana burned + * @param network The Network in context + * @param slotIndex The slot index + * @returns The slot mana burned response. + */ +export function useSlotManaBurned(slotIndex: string | null) { + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [slotManaBurned, setSlotManaBurned] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + setSlotManaBurned(null); + if (slotIndex) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .getManaBurnedForSlot({ + network, + slotIndex, + }) + .then((response) => { + if (isMounted && !response.error) { + setSlotManaBurned(response ?? null); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, slotIndex]); + + return { slotManaBurned, isLoading }; +} diff --git a/client/src/helpers/stardust/valueFormatHelper.spec.ts b/client/src/helpers/stardust/valueFormatHelper.spec.ts index d64913fef..5dd306211 100644 --- a/client/src/helpers/stardust/valueFormatHelper.spec.ts +++ b/client/src/helpers/stardust/valueFormatHelper.spec.ts @@ -11,6 +11,10 @@ const tokenInfo = { describe("formatAmount", () => { describe("with number values", () => { + test("should format 0 subunit properly", () => { + expect(formatAmount(0, tokenInfo)).toBe("0 IOTA"); + }); + test("should format 1 subunit properly", () => { expect(formatAmount(1, tokenInfo)).toBe("0.000001 IOTA"); }); @@ -73,6 +77,10 @@ describe("formatAmount", () => { }); describe("with bigint values", () => { + test("should format 0 subunit properly", () => { + expect(formatAmount(0n, tokenInfo)).toBe("0 IOTA"); + }); + test("should format 1 subunit properly", () => { expect(formatAmount(1n, tokenInfo)).toBe("0.000001 IOTA"); }); @@ -135,6 +143,10 @@ describe("formatAmount", () => { }); describe("with string values", () => { + test("should format 0 subunit properly", () => { + expect(formatAmount("0", tokenInfo)).toBe("0 IOTA"); + }); + test("should format 1 subunit properly", () => { expect(formatAmount("1", tokenInfo)).toBe("0.000001 IOTA"); }); @@ -202,7 +214,7 @@ describe("formatAmount", () => { }); test("should not break with Number null", () => { - expect(formatAmount(Number(null), tokenInfo)).toBe(""); + expect(formatAmount(Number(null), tokenInfo)).toBe("0 IOTA"); }); test("should not break with String undefined", () => { diff --git a/client/src/helpers/stardust/valueFormatHelper.tsx b/client/src/helpers/stardust/valueFormatHelper.tsx index 87dd2c574..02afc1dcb 100644 --- a/client/src/helpers/stardust/valueFormatHelper.tsx +++ b/client/src/helpers/stardust/valueFormatHelper.tsx @@ -23,6 +23,10 @@ export function formatAmount( decimalPlaces: number = 2, trailingDecimals?: boolean, ): string { + if (value === 0 || value === 0n) { + return `0 ${formatFull ? tokenInfo.subunit ?? tokenInfo.unit : tokenInfo.unit}`; + } + if (!value || value === "null" || value === "undefined") { return ""; } diff --git a/client/src/models/api/nova/stats/ISlotManaBurnedRequest.ts b/client/src/models/api/nova/stats/ISlotManaBurnedRequest.ts new file mode 100644 index 000000000..b7002e9ac --- /dev/null +++ b/client/src/models/api/nova/stats/ISlotManaBurnedRequest.ts @@ -0,0 +1,11 @@ +export interface ISlotManaBurnedRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The slot index to get the mana burned for. + */ + slotIndex: string; +} diff --git a/client/src/models/api/nova/stats/ISlotManaBurnedResponse.ts b/client/src/models/api/nova/stats/ISlotManaBurnedResponse.ts new file mode 100644 index 000000000..4d0c60486 --- /dev/null +++ b/client/src/models/api/nova/stats/ISlotManaBurnedResponse.ts @@ -0,0 +1,6 @@ +import { IResponse } from "../IResponse"; + +export interface ISlotManaBurnedResponse extends IResponse { + slotIndex?: number; + manaBurned?: number; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index ab0639152..527464eca 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -55,6 +55,8 @@ import { ITaggedOutputsRequest } from "~/models/api/nova/ITaggedOutputsRequest"; import { IEpochAnalyticStats } from "~/models/api/nova/stats/IEpochAnalyticStats"; import { IEpochAnalyticStatsRequest } from "~/models/api/nova/stats/IEpochAnalyticStatsRequest"; import { IValidatorStatsResponse } from "~/models/api/nova/IValidatorStatsResponse"; +import { ISlotManaBurnedRequest } from "~/models/api/nova/stats/ISlotManaBurnedRequest"; +import { ISlotManaBurnedResponse } from "~/models/api/nova/stats/ISlotManaBurnedResponse"; /** * Class to handle api communications on nova. @@ -315,6 +317,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/epoch/stats/${request.network}/${request.epochIndex}`, "get"); } + /** + * Get the mana burned for slot. + * @param request The mana burned request. + * @returns The epoch stats response. + */ + public async getManaBurnedForSlot(request: ISlotManaBurnedRequest): Promise { + return this.callApi(`nova/slot/mana-burned/${request.network}/${request.slotIndex}`, "get"); + } + /** * Get the current cached validators. * @param request The request to send.