From a03f72ab416c781bfd517bd017171380792f6ce4 Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 26 Feb 2024 16:47:54 +0100 Subject: [PATCH] Feat: Add commitment id in slots feed (#1182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Cache latest slot commitments in novaFeed * feat: Add endpoint to fetch "latest slot commitments" (nova) * feat: Add SlotCommitment info to LandingSlotSection * feat: Add commitment status to SlotFeed (latest vs finalized topic => commited vs finalized slotStatus) * feat: Ignore unresolved import on api (silence lint) * feat: Silence lint unsafe-return in novaFeed * chore: update naming convention * chore: update naming convention * chore: cleanup code redundancy and debris * chore: cleanup code redundancy and debris --------- Co-authored-by: Branko Bosnic Co-authored-by: BegoƱa Alvarez --- .../ILatestSlotCommitmentsResponse.ts | 18 ++++ api/src/routes.ts | 6 ++ api/src/routes/nova/commitment/latest/get.ts | 30 +++++++ api/src/services/nova/feed/novaFeed.ts | 59 ++++++++++++ .../nova/landing/LandingSlotSection.scss | 20 ++++- .../nova/landing/LandingSlotSection.tsx | 41 +++++++-- client/src/app/lib/enums/index.ts | 1 - client/src/app/lib/enums/slot-state.enums.ts | 16 ---- client/src/app/routes/nova/SlotPage.tsx | 21 +---- client/src/helpers/nova/hooks/useSlotsFeed.ts | 89 ++++++++++++++----- .../nova/ILatestSlotCommitmentsResponse.ts | 16 ++++ client/src/services/nova/novaApiClient.ts | 10 +++ 12 files changed, 259 insertions(+), 68 deletions(-) create mode 100644 api/src/models/api/nova/commitment/ILatestSlotCommitmentsResponse.ts create mode 100644 api/src/routes/nova/commitment/latest/get.ts delete mode 100644 client/src/app/lib/enums/index.ts delete mode 100644 client/src/app/lib/enums/slot-state.enums.ts create mode 100644 client/src/models/api/nova/ILatestSlotCommitmentsResponse.ts diff --git a/api/src/models/api/nova/commitment/ILatestSlotCommitmentsResponse.ts b/api/src/models/api/nova/commitment/ILatestSlotCommitmentsResponse.ts new file mode 100644 index 000000000..c2818376d --- /dev/null +++ b/api/src/models/api/nova/commitment/ILatestSlotCommitmentsResponse.ts @@ -0,0 +1,18 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { SlotCommitment } from "@iota/sdk-nova"; +import { IResponse } from "../../IResponse"; + +export enum SlotCommitmentStatus { + Committed = "committed", + Finalized = "finalized", +} + +export interface ISlotCommitmentWrapper { + status: SlotCommitmentStatus; + slotCommitment: SlotCommitment; +} + +export interface ILatestSlotCommitmentResponse extends IResponse { + slotCommitments: ISlotCommitmentWrapper[]; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index dad8537aa..725bda925 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -256,5 +256,11 @@ export const routes: IRoute[] = [ }, { path: "/nova/block/:network/:blockId", method: "get", folder: "nova/block", func: "get" }, { path: "/nova/block/metadata/:network/:blockId", method: "get", folder: "nova/block/metadata", func: "get" }, + { + path: "/nova/commitment/latest/:network", + method: "get", + folder: "nova/commitment/latest", + func: "get", + }, { path: "/nova/slot/:network/:slotIndex", method: "get", folder: "nova/slot", func: "get" }, ]; diff --git a/api/src/routes/nova/commitment/latest/get.ts b/api/src/routes/nova/commitment/latest/get.ts new file mode 100644 index 000000000..784fdaff6 --- /dev/null +++ b/api/src/routes/nova/commitment/latest/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { ILatestSlotCommitmentResponse } from "../../../../models/api/nova/commitment/ILatestSlotCommitmentsResponse"; +import { IConfiguration } from "../../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../../models/db/protocolVersion"; +import { NetworkService } from "../../../../services/networkService"; +import { NovaFeed } from "../../../../services/nova/feed/novaFeed"; +import { ValidationHelper } from "../../../../utils/validationHelper"; + +/** + * Get the latest slot commitments. + * @param _ The configuration. + * @param request The request. + * @param request.network The network in context. + * @returns The response. + */ +export async function get(_: IConfiguration, request: { network: string }): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return { error: "Endpoint available only on Nova networks.", slotCommitments: [] }; + } + + const feedService = ServiceFactory.get(`feed-${request.network}`); + const slotCommitments = feedService.getLatestSlotCommitments; + + return { slotCommitments }; +} diff --git a/api/src/services/nova/feed/novaFeed.ts b/api/src/services/nova/feed/novaFeed.ts index 1fc1a2447..e7b7a8a44 100644 --- a/api/src/services/nova/feed/novaFeed.ts +++ b/api/src/services/nova/feed/novaFeed.ts @@ -1,13 +1,17 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ import { Block, Client, IBlockMetadata, SlotCommitment } from "@iota/sdk-nova"; import { ClassConstructor, plainToInstance } from "class-transformer"; import { ServiceFactory } from "../../../factories/serviceFactory"; import logger from "../../../logger"; +import { ISlotCommitmentWrapper, SlotCommitmentStatus } from "../../../models/api/nova/commitment/ILatestSlotCommitmentsResponse"; import { IFeedUpdate } from "../../../models/api/nova/feed/IFeedUpdate"; import { INetwork } from "../../../models/db/INetwork"; import { NodeInfoService } from "../nodeInfoService"; +const LATEST_SLOT_COMMITMENT_LIMIT = 30; + /** * Wrapper class around Nova MqttClient. * Streaming blocks from mqtt (upstream) to explorer-client connections (downstream). @@ -25,6 +29,11 @@ export class NovaFeed { */ private _mqttClient: Client; + /** + * The latest slot commitments cache. + */ + private readonly latestSlotCommitmentCache: ISlotCommitmentWrapper[] = []; + /** * The network in context. */ @@ -54,6 +63,14 @@ export class NovaFeed { }); } + /** + * Get the latest slot commitment cache state. + * @returns The latest slot commitments. + */ + public get getLatestSlotCommitments() { + return this.latestSlotCommitmentCache; + } + /** * Subscribe to the blocks nova feed. * @param id The id of the subscriber. @@ -124,10 +141,26 @@ export class NovaFeed { // eslint-disable-next-line no-void void this.broadcastBlock(update); + + // eslint-disable-next-line no-void + void this.updateLatestSlotCommitmentCache(slotCommitment, true); } catch { logger.error("[NovaFeed]: Failed broadcasting finalized slot downstream."); } }); + + // eslint-disable-next-line no-void + void this._mqttClient.listenMqtt(["commitments/latest"], async (_, message) => { + try { + const deserializedMessage: { topic: string; payload: string } = JSON.parse(message); + const slotCommitment: SlotCommitment = JSON.parse(deserializedMessage.payload); + + // eslint-disable-next-line no-void + void this.updateLatestSlotCommitmentCache(slotCommitment, false); + } catch { + logger.error("[NovaFeed]: Failed broadcasting commited slot downstream."); + } + }); } private parseMqttPayloadMessage(cls: ClassConstructor, serializedMessage: string): T { @@ -159,4 +192,30 @@ export class NovaFeed { } } } + + /** + * Updates the slot commitment cache. + * @param newSlotCommitment The new slot commitment. + * @param isFinalized Did the SlotCommitment get emitted from the 'commitments/finalized' topic or not ('commitments/latest'). + */ + private async updateLatestSlotCommitmentCache(newSlotCommitment: SlotCommitment, isFinalized: boolean): Promise { + if (!this.latestSlotCommitmentCache.map((commitment) => commitment.slotCommitment.slot).includes(newSlotCommitment.slot)) { + this.latestSlotCommitmentCache.unshift({ + slotCommitment: newSlotCommitment, + status: isFinalized ? SlotCommitmentStatus.Finalized : SlotCommitmentStatus.Committed, + }); + + if (this.latestSlotCommitmentCache.length > LATEST_SLOT_COMMITMENT_LIMIT) { + this.latestSlotCommitmentCache.pop(); + } + } else if (isFinalized) { + const commitmentToUpdate = this.latestSlotCommitmentCache.find( + (commitment) => commitment.slotCommitment.slot === newSlotCommitment.slot, + ); + + if (commitmentToUpdate) { + commitmentToUpdate.status = SlotCommitmentStatus.Finalized; + } + } + } } diff --git a/client/src/app/components/nova/landing/LandingSlotSection.scss b/client/src/app/components/nova/landing/LandingSlotSection.scss index c680a3bc6..8e2b963c4 100644 --- a/client/src/app/components/nova/landing/LandingSlotSection.scss +++ b/client/src/app/components/nova/landing/LandingSlotSection.scss @@ -17,7 +17,8 @@ margin: 0 20px 20px; .slots-feed__item { - display: flex; + display: grid; + grid-template-columns: 1fr 3fr 1fr 1fr; margin: 0px 12px; align-items: center; line-height: 32px; @@ -25,6 +26,10 @@ background-color: $gray-5; border-radius: 4px; + &.basic { + grid-template-columns: none; + } + &.transparent { background-color: transparent; } @@ -32,6 +37,19 @@ &:not(:last-child) { margin-bottom: 20px; } + + .slot__index, + .slot__commitment-id, + .slot__rmc, + .slot__status { + display: flex; + margin: 0 auto; + justify-content: center; + } + + .slot__commitment-id { + width: 220px; + } } } } diff --git a/client/src/app/components/nova/landing/LandingSlotSection.tsx b/client/src/app/components/nova/landing/LandingSlotSection.tsx index 678e825a2..6da6b6847 100644 --- a/client/src/app/components/nova/landing/LandingSlotSection.tsx +++ b/client/src/app/components/nova/landing/LandingSlotSection.tsx @@ -1,12 +1,15 @@ import React from "react"; import useSlotsFeed from "~/helpers/nova/hooks/useSlotsFeed"; -import "./LandingSlotSection.scss"; import ProgressBar from "./ProgressBar"; +import { Utils } from "@iota/sdk-wasm-nova/web"; +import Spinner from "../../Spinner"; +import TruncatedId from "../../stardust/TruncatedId"; +import "./LandingSlotSection.scss"; const LandingSlotSection: React.FC = () => { - const { currentSlot, currentSlotProgressPercent, latestSlots } = useSlotsFeed(); + const { currentSlotIndex, currentSlotProgressPercent, latestSlotIndexes, latestSlotCommitments } = useSlotsFeed(); - if (currentSlot === null || currentSlotProgressPercent === null) { + if (currentSlotIndex === null || currentSlotProgressPercent === null) { return null; } @@ -15,13 +18,33 @@ const LandingSlotSection: React.FC = () => {

Latest Slots

-
{currentSlot}
-
- {latestSlots?.map((slot) => ( -
- {slot} +
+
{currentSlotIndex}
- ))} + + {latestSlotIndexes?.map((slot) => { + const commitmentWrapper = latestSlotCommitments?.find((commitment) => commitment.slotCommitment.slot === slot) ?? null; + const commitmentId = !commitmentWrapper ? ( + + ) : ( + + ); + const referenceManaCost = !commitmentWrapper ? ( + + ) : ( + commitmentWrapper.slotCommitment.referenceManaCost.toString() + ); + const slotStatus = !commitmentWrapper ? "pending" : commitmentWrapper.status; + + return ( +
+
{slot}
+
{commitmentId}
+
{referenceManaCost}
+
{slotStatus}
+
+ ); + })}
); diff --git a/client/src/app/lib/enums/index.ts b/client/src/app/lib/enums/index.ts deleted file mode 100644 index 877581b6e..000000000 --- a/client/src/app/lib/enums/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./slot-state.enums"; diff --git a/client/src/app/lib/enums/slot-state.enums.ts b/client/src/app/lib/enums/slot-state.enums.ts deleted file mode 100644 index 3a190a546..000000000 --- a/client/src/app/lib/enums/slot-state.enums.ts +++ /dev/null @@ -1,16 +0,0 @@ -export enum SlotState { - /** - * The slot is pending. - */ - Pending = "pending", - - /** - * The slot is committed. - */ - Committed = "committed", - - /** - * The slot is finalized. - */ - Finalized = "finalized", -} diff --git a/client/src/app/routes/nova/SlotPage.tsx b/client/src/app/routes/nova/SlotPage.tsx index a8d6aed2e..56d538e1e 100644 --- a/client/src/app/routes/nova/SlotPage.tsx +++ b/client/src/app/routes/nova/SlotPage.tsx @@ -1,21 +1,12 @@ import React from "react"; import useuseSlotDetails from "~/helpers/nova/hooks/useSlotDetails"; -import StatusPill from "~/app/components/nova/StatusPill"; import PageDataRow, { IPageDataRow } from "~/app/components/nova/PageDataRow"; import Modal from "~/app/components/Modal"; import mainHeaderMessage from "~assets/modals/nova/slot/main-header.json"; import NotFound from "~/app/components/NotFound"; -import { SlotState } from "~/app/lib/enums"; import { RouteComponentProps } from "react-router-dom"; -import { PillStatus } from "~/app/lib/ui/enums"; import "./SlotPage.scss"; -const SLOT_STATE_TO_PILL_STATUS: Record = { - [SlotState.Pending]: PillStatus.Pending, - [SlotState.Committed]: PillStatus.Success, - [SlotState.Finalized]: PillStatus.Success, -}; - export default function SlotPage({ match: { params: { network, slotIndex }, @@ -27,18 +18,15 @@ export default function SlotPage({ const { slotCommitment } = useuseSlotDetails(network, slotIndex); const parsedSlotIndex = parseSlotIndex(slotIndex); - const slotState = slotCommitment ? SlotState.Finalized : SlotState.Pending; - const pillStatus: PillStatus = SLOT_STATE_TO_PILL_STATUS[slotState]; const dataRows: IPageDataRow[] = [ { label: "Slot Index", - value: slotCommitment?.slot || parsedSlotIndex, - highlight: true, + value: parsedSlotIndex ?? "-", }, { label: "RMC", - value: slotCommitment?.referenceManaCost.toString(), + value: slotCommitment?.referenceManaCost?.toString() ?? "-", }, ]; @@ -59,11 +47,6 @@ export default function SlotPage({

Slot

- {parsedSlotIndex && ( -
- -
- )} {parsedSlotIndex ? (
diff --git a/client/src/helpers/nova/hooks/useSlotsFeed.ts b/client/src/helpers/nova/hooks/useSlotsFeed.ts index 626250c0b..fc39aa8c4 100644 --- a/client/src/helpers/nova/hooks/useSlotsFeed.ts +++ b/client/src/helpers/nova/hooks/useSlotsFeed.ts @@ -1,53 +1,98 @@ import moment from "moment"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { useIsMounted } from "~/helpers/hooks/useIsMounted"; +import { ISlotCommitmentWrapper } from "~/models/api/nova/ILatestSlotCommitmentsResponse"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; +import { useNetworkInfoNova } from "../networkInfo"; import { useNovaTimeConvert } from "./useNovaTimeConvert"; const DEFAULT_SLOT_LIMIT = 10; +const MAX_LATEST_SLOT_COMMITMENTS = 20; + +const CHECK_SLOT_INDEX_INTERVAL = 950; +const CHECK_SLOT_COMMITMENTS_INTERVAL = 3500; export default function useSlotsFeed(slotsLimit: number = DEFAULT_SLOT_LIMIT): { - currentSlot: number | null; + currentSlotIndex: number | null; currentSlotProgressPercent: number | null; - latestSlots: number[] | null; + latestSlotIndexes: number[] | null; + latestSlotCommitments: ISlotCommitmentWrapper[]; } { + const isMounted = useIsMounted(); + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); const { unixTimestampToSlotIndex, slotIndexToUnixTimeRange } = useNovaTimeConvert(); - const [currentSlot, setCurrentSlot] = useState(null); - const [latestSlots, setLatestSlots] = useState(null); + const [currentSlotIndex, setCurrentSlotIndex] = useState(null); + const [latestSlotIndexes, setLatestSlotIndexes] = useState(null); + + const [latestSlotCommitments, setLatestSlotCommitments] = useState([]); + const [currentSlotProgressPercent, setCurrentSlotProgressPercent] = useState(null); - const [slotTimeUpdateHandle, setSlotTimeUpdateHandle] = useState(null); - const checkCurrentSlot = () => { + const [slotIndexCheckerHandle, setSlotIndexCheckerHandle] = useState(null); + const [slotCommitmentsCheckerHandle, setSlotCommitmentsCheckerHandle] = useState(null); + + const checkCurrentSlotIndex = () => { if (unixTimestampToSlotIndex && slotIndexToUnixTimeRange) { const now = moment().unix(); const currentSlotIndex = unixTimestampToSlotIndex(now); const slotTimeRange = slotIndexToUnixTimeRange(currentSlotIndex); const slotProgressPercent = Math.trunc(((now - slotTimeRange.from) / (slotTimeRange.to - 1 - slotTimeRange.from)) * 100); - setCurrentSlot(currentSlotIndex); - setCurrentSlotProgressPercent(slotProgressPercent); - setLatestSlots(Array.from({ length: slotsLimit - 1 }, (_, i) => currentSlotIndex - 1 - i)); + + if (isMounted) { + setCurrentSlotIndex(currentSlotIndex); + setCurrentSlotProgressPercent(slotProgressPercent); + setLatestSlotIndexes(Array.from({ length: slotsLimit - 1 }, (_, i) => currentSlotIndex - 1 - i)); + } } }; + const getLatestSlotCommitments = useCallback(async () => { + if (apiClient) { + const latestSlotCommitments = await apiClient.latestSlotCommitments(network); + if (isMounted && latestSlotCommitments.slotCommitments && latestSlotCommitments.slotCommitments.length > 0) { + setLatestSlotCommitments(latestSlotCommitments.slotCommitments.slice(0, MAX_LATEST_SLOT_COMMITMENTS)); + } + } + }, [network]); + useEffect(() => { - if (slotTimeUpdateHandle === null) { - checkCurrentSlot(); - const intervalTimerHandle = setInterval(() => { - checkCurrentSlot(); - }, 950); + if (slotIndexCheckerHandle === null) { + getLatestSlotCommitments(); + checkCurrentSlotIndex(); + + const slotCommitmentCheckerHandle = setInterval(() => { + getLatestSlotCommitments(); + }, CHECK_SLOT_COMMITMENTS_INTERVAL); + + const slotIndexIntervalHandle = setInterval(() => { + checkCurrentSlotIndex(); + }, CHECK_SLOT_INDEX_INTERVAL); - setSlotTimeUpdateHandle(intervalTimerHandle); + setSlotCommitmentsCheckerHandle(slotCommitmentCheckerHandle); + setSlotIndexCheckerHandle(slotIndexIntervalHandle); } return () => { - if (slotTimeUpdateHandle) { - clearInterval(slotTimeUpdateHandle); + if (slotCommitmentsCheckerHandle) { + clearInterval(slotCommitmentsCheckerHandle); } - setSlotTimeUpdateHandle(null); - setCurrentSlot(null); + + if (slotIndexCheckerHandle) { + clearInterval(slotIndexCheckerHandle); + } + + setSlotCommitmentsCheckerHandle(null); + setSlotIndexCheckerHandle(null); + setCurrentSlotIndex(null); setCurrentSlotProgressPercent(null); - setLatestSlots(null); + setLatestSlotIndexes(null); + setLatestSlotCommitments([]); }; }, []); - return { currentSlot, currentSlotProgressPercent, latestSlots }; + return { currentSlotIndex, currentSlotProgressPercent, latestSlotIndexes, latestSlotCommitments }; } diff --git a/client/src/models/api/nova/ILatestSlotCommitmentsResponse.ts b/client/src/models/api/nova/ILatestSlotCommitmentsResponse.ts new file mode 100644 index 000000000..68f04a973 --- /dev/null +++ b/client/src/models/api/nova/ILatestSlotCommitmentsResponse.ts @@ -0,0 +1,16 @@ +import { SlotCommitment } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "../IResponse"; + +export enum SlotCommitmentStatus { + Committed = "committed", + Finalized = "finalized", +} + +export interface ISlotCommitmentWrapper { + status: SlotCommitmentStatus; + slotCommitment: SlotCommitment; +} + +export interface ILatestSlotCommitmentResponse extends IResponse { + slotCommitments: ISlotCommitmentWrapper[]; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index bff44cf77..6441f66f9 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -35,6 +35,7 @@ import { ITransactionDetailsRequest } from "~/models/api/nova/ITransactionDetail import { ITransactionDetailsResponse } from "~/models/api/nova/ITransactionDetailsResponse"; import { ICongestionRequest } from "~/models/api/nova/ICongestionRequest"; import { ICongestionResponse } from "~/models/api/nova/ICongestionResponse"; +import { ILatestSlotCommitmentResponse } from "~/models/api/nova/ILatestSlotCommitmentsResponse"; /** * Class to handle api communications on nova. @@ -161,6 +162,15 @@ export class NovaApiClient extends ApiClient { ); } + /** + * Get the latest slot commitments. + * @param network The network in context. + * @returns The latest slot commitments response. + */ + public async latestSlotCommitments(network: string): Promise { + return this.callApi(`nova/commitment/latest/${network}`, "get"); + } + /** * Get the account congestion. * @param request The request to send.