From f4e01fdbf0fa4ffe106aa0f36156dcc4450094f0 Mon Sep 17 00:00:00 2001 From: Bran <52735957+brancoder@users.noreply.github.com> Date: Mon, 26 Feb 2024 12:03:09 +0100 Subject: [PATCH 1/4] Feat: Add Nova Transaction page (#1162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add nova Transaction page * fix: bump sdk version and fix imports * Update TransactionMetadataSection and TransactionPage components * feat: add search by transaction id * feat: add link to tx in output page --------- Co-authored-by: Begoña Álvarez de la Cruz --- api/src/models/api/nova/ISearchResponse.ts | 5 + .../api/nova/ITransactionDetailsRequest.ts | 11 + .../api/nova/ITransactionDetailsResponse.ts | 11 + api/src/routes.ts | 6 + api/src/routes/nova/transaction/get.ts | 30 +++ api/src/services/nova/novaApiService.ts | 25 +++ api/src/utils/nova/searchExecutor.ts | 15 ++ .../section/TransactionMetadataSection.tsx | 6 +- client/src/app/routes.tsx | 2 + client/src/app/routes/nova/OutputPage.tsx | 2 +- client/src/app/routes/nova/Search.tsx | 3 +- .../src/app/routes/nova/TransactionPage.scss | 71 +++++++ .../src/app/routes/nova/TransactionPage.tsx | 198 ++++++++++++++++++ .../nova/hooks/useTransactionIncludedBlock.ts | 48 +++++ client/src/models/api/nova/ISearchResponse.ts | 5 + .../api/nova/ITransactionDetailsRequest.ts | 11 + .../api/nova/ITransactionDetailsResponse.ts | 9 + client/src/services/nova/novaApiClient.ts | 11 + 18 files changed, 463 insertions(+), 6 deletions(-) create mode 100644 api/src/models/api/nova/ITransactionDetailsRequest.ts create mode 100644 api/src/models/api/nova/ITransactionDetailsResponse.ts create mode 100644 api/src/routes/nova/transaction/get.ts create mode 100644 client/src/app/routes/nova/TransactionPage.scss create mode 100644 client/src/app/routes/nova/TransactionPage.tsx create mode 100644 client/src/helpers/nova/hooks/useTransactionIncludedBlock.ts create mode 100644 client/src/models/api/nova/ITransactionDetailsRequest.ts create mode 100644 client/src/models/api/nova/ITransactionDetailsResponse.ts diff --git a/api/src/models/api/nova/ISearchResponse.ts b/api/src/models/api/nova/ISearchResponse.ts index c4b8925b5..f9762dd30 100644 --- a/api/src/models/api/nova/ISearchResponse.ts +++ b/api/src/models/api/nova/ISearchResponse.ts @@ -39,4 +39,9 @@ export interface ISearchResponse extends IResponse { * Nft id if it was found. */ nftId?: string; + + /** + * Transaction included block. + */ + transactionBlock?: Block; } diff --git a/api/src/models/api/nova/ITransactionDetailsRequest.ts b/api/src/models/api/nova/ITransactionDetailsRequest.ts new file mode 100644 index 000000000..d5b298039 --- /dev/null +++ b/api/src/models/api/nova/ITransactionDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface ITransactionDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The transaction id to get the details for. + */ + transactionId: string; +} diff --git a/api/src/models/api/nova/ITransactionDetailsResponse.ts b/api/src/models/api/nova/ITransactionDetailsResponse.ts new file mode 100644 index 000000000..b9aef5eea --- /dev/null +++ b/api/src/models/api/nova/ITransactionDetailsResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +import { Block } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface ITransactionDetailsResponse extends IResponse { + /** + * Transaction included block. + */ + block?: Block; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index b5051dbc4..60a25ca9d 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -242,6 +242,12 @@ export const routes: IRoute[] = [ folder: "nova/account/foundries", func: "get", }, + { + path: "/nova/transaction/:network/:transactionId", + method: "get", + folder: "nova/transaction", + func: "get", + }, { path: "/nova/account/congestion/:network/:accountId", method: "get", diff --git a/api/src/routes/nova/transaction/get.ts b/api/src/routes/nova/transaction/get.ts new file mode 100644 index 000000000..01a7cbbc4 --- /dev/null +++ b/api/src/routes/nova/transaction/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../factories/serviceFactory"; +import { ITransactionDetailsRequest } from "../../../models/api/nova/ITransactionDetailsRequest"; +import { ITransactionDetailsResponse } from "../../../models/api/nova/ITransactionDetailsResponse"; +import { IConfiguration } from "../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../models/db/protocolVersion"; +import { NetworkService } from "../../../services/networkService"; +import { NovaApiService } from "../../../services/nova/novaApiService"; +import { ValidationHelper } from "../../../utils/validationHelper"; + +/** + * Find the object from the network. + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: ITransactionDetailsRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.transactionId, "transactionId"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.transactionIncludedBlock(request.transactionId); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 48d25f8fe..06bc6c426 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -16,6 +16,7 @@ import { INftDetailsResponse } from "../../models/api/nova/INftDetailsResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; import { IRewardsResponse } from "../../models/api/nova/IRewardsResponse"; import { ISearchResponse } from "../../models/api/nova/ISearchResponse"; +import { ITransactionDetailsResponse } from "../../models/api/nova/ITransactionDetailsResponse"; import { INetwork } from "../../models/db/INetwork"; import { HexHelper } from "../../utils/hexHelper"; import { SearchExecutor } from "../../utils/nova/searchExecutor"; @@ -86,6 +87,30 @@ export class NovaApiService { } } + /** + * Get the transaction included block. + * @param transactionId The transaction id to get the details. + * @returns The item details. + */ + public async transactionIncludedBlock(transactionId: string): Promise { + transactionId = HexHelper.addPrefix(transactionId); + try { + const block = await this.client.getIncludedBlock(transactionId); + + if (!block) { + return { error: `Couldn't find block from transaction id ${transactionId}` }; + } + if (block && Object.keys(block).length > 0) { + return { + block, + }; + } + } catch (e) { + logger.error(`Failed fetching block with transaction id ${transactionId}. Cause: ${e}`); + return { error: "Block fetch failed." }; + } + } + /** * Get the output details. * @param outputId The output id to get the details. diff --git a/api/src/utils/nova/searchExecutor.ts b/api/src/utils/nova/searchExecutor.ts index 9c276abba..bb3785fc8 100644 --- a/api/src/utils/nova/searchExecutor.ts +++ b/api/src/utils/nova/searchExecutor.ts @@ -97,6 +97,21 @@ export class SearchExecutor { ); } + if (searchQuery.transactionId) { + promises.push( + this.executeQuery( + this.apiService.transactionIncludedBlock(searchQuery.transactionId), + (response) => { + promisesResult = { + transactionBlock: response.block, + error: response.error || response.message, + }; + }, + "Transaction included block fetch failed", + ), + ); + } + await Promise.any(promises).catch((_) => {}); if (promisesResult !== null) { diff --git a/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx b/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx index b1d8e2e19..eea4c3a02 100644 --- a/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx +++ b/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { TRANSACTION_FAILURE_REASON_STRINGS, Transaction, TransactionMetadata } from "@iota/sdk-wasm-nova/web"; +import { TRANSACTION_FAILURE_REASON_STRINGS, Transaction, TransactionMetadata, Utils } from "@iota/sdk-wasm-nova/web"; import React from "react"; import "./TransactionMetadataSection.scss"; import Spinner from "../../../Spinner"; @@ -14,7 +14,7 @@ interface TransactionMetadataSectionProps { } const TransactionMetadataSection: React.FC = ({ transaction, transactionMetadata, metadataError }) => { - const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const { name: network, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); return (
@@ -73,7 +73,7 @@ const TransactionMetadataSection: React.FC = ({
diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index cd52d8ed5..f45fbe482 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -35,6 +35,7 @@ import NftRedirectRoute from "./routes/stardust/NftRedirectRoute"; import StardustOutputList from "./routes/stardust/OutputList"; import StardustOutputPage from "./routes/stardust/OutputPage"; import NovaBlockPage from "./routes/nova/Block"; +import NovaTransactionPage from "./routes/nova/TransactionPage"; import NovaOutputPage from "./routes/nova/OutputPage"; import NovaSearch from "./routes/nova/Search"; import StardustSearch from "./routes/stardust/Search"; @@ -178,6 +179,7 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom , , , + , ]; return ( diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index 7c9b2007f..1fd3769f6 100644 --- a/client/src/app/routes/nova/OutputPage.tsx +++ b/client/src/app/routes/nova/OutputPage.tsx @@ -112,7 +112,7 @@ const OutputPage: React.FC> = ({
Transaction ID
- +
)} diff --git a/client/src/app/routes/nova/Search.tsx b/client/src/app/routes/nova/Search.tsx index 6701c8f6c..9c30eab28 100644 --- a/client/src/app/routes/nova/Search.tsx +++ b/client/src/app/routes/nova/Search.tsx @@ -105,9 +105,8 @@ const Search: React.FC> = (props) => { } else if (response.output) { route = "output"; routeParam = response.output.metadata.outputId; - } else if (response.transactionId) { + } else if (response.transactionBlock) { route = "transaction"; - routeParam = response.transactionId; } else if (response.foundryId) { route = "foundry"; routeParam = response.foundryId; diff --git a/client/src/app/routes/nova/TransactionPage.scss b/client/src/app/routes/nova/TransactionPage.scss new file mode 100644 index 000000000..74c212e45 --- /dev/null +++ b/client/src/app/routes/nova/TransactionPage.scss @@ -0,0 +1,71 @@ +@import "../../../scss/fonts"; +@import "../../../scss/mixins"; +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; + +.transaction-page { + display: flex; + flex-direction: column; + + .wrapper { + display: flex; + justify-content: center; + + .inner { + display: flex; + flex: 1; + flex-direction: column; + max-width: $desktop-width; + margin: 40px 25px; + + @include desktop-down { + flex: unset; + width: 100%; + max-width: 100%; + margin: 40px 24px; + padding-right: 24px; + padding-left: 24px; + + > .row { + flex-direction: column; + } + } + + @include tablet-down { + margin: 28px 0; + } + + .transation-page--header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .section { + padding-top: 44px; + + .section--header { + margin-top: 44px; + } + } + + .link { + @include font-size(14px); + + max-width: 100%; + color: var(--link-color); + font-family: $ibm-plex-mono; + font-weight: normal; + letter-spacing: 0.02em; + line-height: 20px; + } + } + + .section--data { + .amount-transacted { + @include font-size(15px); + font-weight: 700; + } + } + } +} diff --git a/client/src/app/routes/nova/TransactionPage.tsx b/client/src/app/routes/nova/TransactionPage.tsx new file mode 100644 index 000000000..79aebb339 --- /dev/null +++ b/client/src/app/routes/nova/TransactionPage.tsx @@ -0,0 +1,198 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import { BasicBlockBody, SignedTransactionPayload, Utils } from "@iota/sdk-wasm-nova/web"; +import React, { useEffect, useState } from "react"; +import { RouteComponentProps } from "react-router-dom"; +import metadataInfoMessage from "~assets/modals/stardust/block/metadata.json"; +import transactionPayloadMessage from "~assets/modals/stardust/transaction/main-header.json"; +import { useBlockMetadata } from "~helpers/nova/hooks/useBlockMetadata"; +import { useInputsAndOutputs } from "~helpers/nova/hooks/useInputsAndOutputs"; +import { useTransactionIncludedBlock } from "~helpers/nova/hooks/useTransactionIncludedBlock"; +import { formatAmount } from "~helpers/stardust/valueFormatHelper"; +import TabbedSection from "~/app/components/hoc/TabbedSection"; +import Modal from "~/app/components/Modal"; +import Spinner from "~/app/components/Spinner"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import NotFound from "~/app/components/NotFound"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { DateHelper } from "~/helpers/dateHelper"; +import BlockTangleState from "~/app/components/nova/block/BlockTangleState"; +import BlockPayloadSection from "~/app/components/nova/block/section/BlockPayloadSection"; +import TransactionMetadataSection from "~/app/components/nova/block/section/TransactionMetadataSection"; +import "./TransactionPage.scss"; + +export interface TransactionPageProps { + /** + * The network to lookup. + */ + network: string; + + /** + * The transaction to lookup. + */ + transactionId: string; +} + +enum TRANSACTION_PAGE_TABS { + Payload = "Payload", + Metadata = "Metadata", +} + +const TransactionPage: React.FC> = ({ + history, + match: { + params: { network, transactionId }, + }, +}) => { + const { tokenInfo, protocolInfo, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const [block, isIncludedBlockLoading, blockError] = useTransactionIncludedBlock(network, transactionId); + const [inputs, outputs, transferTotal, isInputsAndOutputsLoading] = useInputsAndOutputs(network, block); + const [blockId, setBlockId] = useState(null); + const [blockMetadata] = useBlockMetadata(network, blockId); + const [isFormattedBalance, setIsFormattedBalance] = useState(true); + + useEffect(() => { + if (block && protocolInfo) { + setBlockId(Utils.blockId(block, protocolInfo?.parameters)); + } + }, [block]); + + const tabbedSections: JSX.Element[] = []; + let idx = 0; + if (block) { + tabbedSections.push( + , + ); + } + + if (blockMetadata.metadata?.transactionMetadata) { + tabbedSections.push( + , + ); + } + + if (blockError) { + return ( +
+
+
+
+
+

Transaction

+ + {isIncludedBlockLoading && } +
+ +
+
+
+
+ ); + } + + const transactionContent = block ? ( + +
+
+

General

+
+
+
+
Transaction ID
+
+ +
+
+ {blockId && ( +
+
Included in block
+
+ +
+
+ )} +
+
Issuing Time
+
{DateHelper.formatShort(Number(block.header.issuingTime) / 1000000)}
+
+
+
Slot commitment
+
+ +
+
+
+
Issuer
+
+ +
+
+ {transferTotal !== null && ( +
+
Amount transacted
+
+ setIsFormattedBalance(!isFormattedBalance)} className="pointer margin-r-5"> + {formatAmount(transferTotal, tokenInfo, !isFormattedBalance)} + +
+
+ )} + + {tabbedSections} + +
+ ) : null; + + return ( +
+
+
+
+
+
+

Transaction

+ + {isIncludedBlockLoading && } +
+ {blockMetadata.metadata && block?.header && ( + + )} +
+
+
{transactionContent}
+
+
+
+ ); +}; + +export default TransactionPage; diff --git a/client/src/helpers/nova/hooks/useTransactionIncludedBlock.ts b/client/src/helpers/nova/hooks/useTransactionIncludedBlock.ts new file mode 100644 index 000000000..646377327 --- /dev/null +++ b/client/src/helpers/nova/hooks/useTransactionIncludedBlock.ts @@ -0,0 +1,48 @@ +import { Block } from "@iota/sdk-wasm-nova/web"; +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 { HexHelper } from "~/helpers/stardust/hexHelper"; + +/** + * Fetch transaction included block details + * @param network The Network in context + * @param transactionId The transaction id + * @returns The block, loading bool and an error string. + */ +export function useTransactionIncludedBlock(network: string, transactionId: string | null): [Block | null, boolean, string?] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [block, setBlock] = useState(null); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (transactionId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .transactionIncludedBlockDetails({ + network, + transactionId: HexHelper.addPrefix(transactionId), + }) + .then((response) => { + if (isMounted) { + setBlock(response.block ?? null); + setError(response.error); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, transactionId]); + + return [block, isLoading, error]; +} diff --git a/client/src/models/api/nova/ISearchResponse.ts b/client/src/models/api/nova/ISearchResponse.ts index e84fda3d7..220d64bf9 100644 --- a/client/src/models/api/nova/ISearchResponse.ts +++ b/client/src/models/api/nova/ISearchResponse.ts @@ -57,4 +57,9 @@ export interface ISearchResponse extends IResponse { * Nft details. */ nftDetails?: OutputResponse; + + /** + * Transaction included block. + */ + transactionBlock?: Block; } diff --git a/client/src/models/api/nova/ITransactionDetailsRequest.ts b/client/src/models/api/nova/ITransactionDetailsRequest.ts new file mode 100644 index 000000000..d5b298039 --- /dev/null +++ b/client/src/models/api/nova/ITransactionDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface ITransactionDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The transaction id to get the details for. + */ + transactionId: string; +} diff --git a/client/src/models/api/nova/ITransactionDetailsResponse.ts b/client/src/models/api/nova/ITransactionDetailsResponse.ts new file mode 100644 index 000000000..6e672eaeb --- /dev/null +++ b/client/src/models/api/nova/ITransactionDetailsResponse.ts @@ -0,0 +1,9 @@ +import { Block } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; + +export interface ITransactionDetailsResponse extends IResponse { + /** + * The transaction included block. + */ + block?: Block; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 859be2fcc..5c03dbf0b 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -29,6 +29,8 @@ import { IAddressDetailsRequest } from "~/models/api/nova/address/IAddressDetail import { IAddressDetailsResponse } from "~/models/api/nova/address/IAddressDetailsResponse"; import { IFoundriesResponse } from "~/models/api/nova/foundry/IFoundriesResponse"; import { IFoundriesRequest } from "~/models/api/nova/foundry/IFoundriesRequest"; +import { ITransactionDetailsRequest } from "~/models/api/nova/ITransactionDetailsRequest"; +import { ITransactionDetailsResponse } from "~/models/api/nova/ITransactionDetailsResponse"; import { ICongestionRequest } from "~/models/api/nova/ICongestionRequest"; import { ICongestionResponse } from "~/models/api/nova/ICongestionResponse"; @@ -72,6 +74,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/block/metadata/${request.network}/${request.blockId}`, "get"); } + /** + * Get the transaction included block. + * @param request The request to send. + * @returns The response from the request. + */ + public async transactionIncludedBlockDetails(request: ITransactionDetailsRequest): Promise { + return this.callApi(`nova/transaction/${request.network}/${request.transactionId}`, "get"); + } + /** * Get the output details. * @param request The request to send. From 0f650adc513c4e2f4d6305b7fe72713c44567a18 Mon Sep 17 00:00:00 2001 From: JCNoguera <88061365+VmMad@users.noreply.github.com> Date: Mon, 26 Feb 2024 15:54:36 +0100 Subject: [PATCH 2/4] feat: add slot page (#1163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add slot page * feat: add utxo changes * refactor: cleanup unused code and add not found component * fix: lint * fix: run formatter * chore: improve slot page * refactor: reuse pill status component * refactor: rename hook --------- Co-authored-by: Begoña Alvarez --- api/src/models/api/nova/ISlotRequest.ts | 11 +++ api/src/models/api/nova/ISlotResponse.ts | 10 +++ api/src/routes.ts | 1 + api/src/routes/nova/slot/get.ts | 30 +++++++ api/src/services/nova/novaApiService.ts | 16 ++++ .../src/app/components/nova/PageDataRow.tsx | 30 +++++++ .../src/app/components/nova/StatusPill.scss | 41 +++++++++ client/src/app/components/nova/StatusPill.tsx | 42 +++++++++ .../nova/block/BlockTangleState.scss | 37 -------- .../nova/block/BlockTangleState.tsx | 43 ++++----- .../section/TransactionMetadataSection.scss | 42 --------- .../section/TransactionMetadataSection.tsx | 34 +++---- client/src/app/lib/enums/index.ts | 1 + client/src/app/lib/enums/slot-state.enums.ts | 16 ++++ client/src/app/lib/ui/enums/index.ts | 1 + .../src/app/lib/ui/enums/pill-status.enum.ts | 5 ++ client/src/app/routes.tsx | 2 + client/src/app/routes/nova/SlotPage.scss | 65 ++++++++++++++ client/src/app/routes/nova/SlotPage.tsx | 88 +++++++++++++++++++ .../assets/modals/nova/slot/main-header.json | 11 +++ .../src/helpers/nova/hooks/useSlotDetails.ts | 54 ++++++++++++ client/src/models/api/nova/ISlotRequest.ts | 11 +++ client/src/models/api/nova/ISlotResponse.ts | 6 ++ client/src/services/nova/novaApiClient.ts | 11 +++ 24 files changed, 483 insertions(+), 125 deletions(-) create mode 100644 api/src/models/api/nova/ISlotRequest.ts create mode 100644 api/src/models/api/nova/ISlotResponse.ts create mode 100644 api/src/routes/nova/slot/get.ts create mode 100644 client/src/app/components/nova/PageDataRow.tsx create mode 100644 client/src/app/components/nova/StatusPill.scss create mode 100644 client/src/app/components/nova/StatusPill.tsx delete mode 100644 client/src/app/components/nova/block/section/TransactionMetadataSection.scss create mode 100644 client/src/app/lib/enums/index.ts create mode 100644 client/src/app/lib/enums/slot-state.enums.ts create mode 100644 client/src/app/lib/ui/enums/index.ts create mode 100644 client/src/app/lib/ui/enums/pill-status.enum.ts create mode 100644 client/src/app/routes/nova/SlotPage.scss create mode 100644 client/src/app/routes/nova/SlotPage.tsx create mode 100644 client/src/assets/modals/nova/slot/main-header.json create mode 100644 client/src/helpers/nova/hooks/useSlotDetails.ts create mode 100644 client/src/models/api/nova/ISlotRequest.ts create mode 100644 client/src/models/api/nova/ISlotResponse.ts diff --git a/api/src/models/api/nova/ISlotRequest.ts b/api/src/models/api/nova/ISlotRequest.ts new file mode 100644 index 000000000..557fb3337 --- /dev/null +++ b/api/src/models/api/nova/ISlotRequest.ts @@ -0,0 +1,11 @@ +export interface ISlotRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The slot index to get the details for. + */ + slotIndex: string; +} diff --git a/api/src/models/api/nova/ISlotResponse.ts b/api/src/models/api/nova/ISlotResponse.ts new file mode 100644 index 000000000..342e6c65f --- /dev/null +++ b/api/src/models/api/nova/ISlotResponse.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line import/no-unresolved +import { SlotCommitment } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface ISlotResponse extends IResponse { + /** + * The deserialized slot. + */ + slot?: SlotCommitment; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index 60a25ca9d..dad8537aa 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -256,4 +256,5 @@ 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/slot/:network/:slotIndex", method: "get", folder: "nova/slot", func: "get" }, ]; diff --git a/api/src/routes/nova/slot/get.ts b/api/src/routes/nova/slot/get.ts new file mode 100644 index 000000000..40339920f --- /dev/null +++ b/api/src/routes/nova/slot/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../factories/serviceFactory"; +import { ISlotRequest } from "../../../models/api/nova/ISlotRequest"; +import { ISlotResponse } from "../../../models/api/nova/ISlotResponse"; +import { IConfiguration } from "../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../models/db/protocolVersion"; +import { NetworkService } from "../../../services/networkService"; +import { NovaApiService } from "../../../services/nova/novaApiService"; +import { ValidationHelper } from "../../../utils/validationHelper"; + +/** + * Fetch the block from the network. + * @param _ The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(_: IConfiguration, request: ISlotRequest): 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 novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.getSlotCommitment(Number(request.slotIndex)); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 06bc6c426..9290527c2 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -16,6 +16,7 @@ import { INftDetailsResponse } from "../../models/api/nova/INftDetailsResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; import { IRewardsResponse } from "../../models/api/nova/IRewardsResponse"; import { ISearchResponse } from "../../models/api/nova/ISearchResponse"; +import { ISlotResponse } from "../../models/api/nova/ISlotResponse"; import { ITransactionDetailsResponse } from "../../models/api/nova/ITransactionDetailsResponse"; import { INetwork } from "../../models/db/INetwork"; import { HexHelper } from "../../utils/hexHelper"; @@ -306,6 +307,21 @@ export class NovaApiService { return manaRewardsResponse ? { outputId, manaRewards: manaRewardsResponse } : { outputId, message: "Rewards data not found" }; } + /** + * Get the slot commitment. + * @param slotIndex The slot index to get the commitment for. + * @returns The slot commitment. + */ + public async getSlotCommitment(slotIndex: number): Promise { + try { + const slot = await this.client.getCommitmentByIndex(slotIndex); + + return { slot }; + } catch (e) { + logger.error(`Failed fetching slot with slot index ${slotIndex}. Cause: ${e}`); + } + } + /** * Find item on the stardust network. * @param query The query to use for finding items. diff --git a/client/src/app/components/nova/PageDataRow.tsx b/client/src/app/components/nova/PageDataRow.tsx new file mode 100644 index 000000000..57658ed01 --- /dev/null +++ b/client/src/app/components/nova/PageDataRow.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import classNames from "classnames"; +import TruncatedId from "../stardust/TruncatedId"; + +export interface IPageDataRow { + label: string; + value?: string | number; + highlight?: boolean; + truncatedId?: { + id: string; + link?: string; + showCopyButton?: boolean; + }; +} +const PageDataRow = ({ label, value, truncatedId, highlight }: IPageDataRow): React.JSX.Element => { + return ( +
+
{label}
+
+ {truncatedId ? ( + + ) : ( + value + )} +
+
+ ); +}; + +export default PageDataRow; diff --git a/client/src/app/components/nova/StatusPill.scss b/client/src/app/components/nova/StatusPill.scss new file mode 100644 index 000000000..6467cdc5b --- /dev/null +++ b/client/src/app/components/nova/StatusPill.scss @@ -0,0 +1,41 @@ +@import "./../../../scss/fonts"; +@import "./../../../scss/mixins"; +@import "./../../../scss/media-queries"; +@import "./../../../scss/variables"; + +.status-pill { + @include font-size(12px); + + display: flex; + align-items: center; + margin-right: 8px; + padding: 6px 12px; + border: 0; + border-radius: 6px; + outline: none; + color: $gray-midnight; + font-family: $inter; + font-weight: 500; + letter-spacing: 0.5px; + white-space: nowrap; + + @include phone-down { + height: 32px; + } + + &.status__ { + &success { + background-color: var(--message-confirmed-bg); + color: $mint-green-7; + } + + &error { + background-color: var(--message-conflicting-bg); + } + + &pending { + background-color: var(--light-bg); + color: #8493ad; + } + } +} diff --git a/client/src/app/components/nova/StatusPill.tsx b/client/src/app/components/nova/StatusPill.tsx new file mode 100644 index 000000000..289ee81f2 --- /dev/null +++ b/client/src/app/components/nova/StatusPill.tsx @@ -0,0 +1,42 @@ +import classNames from "classnames"; +import React from "react"; +import { PillStatus } from "~/app/lib/ui/enums"; +import Tooltip from "../Tooltip"; +import "./StatusPill.scss"; + +interface IStatusPill { + /** + * Label for the status. + */ + label: string; + /** + * The status of the pill. + */ + status: PillStatus; + /** + * Tooltip explaining further for the label. + */ + tooltip?: string; +} + +const StatusPill: React.FC = ({ label, status, tooltip }): React.JSX.Element => ( + <> +
+ {tooltip ? ( + + {status} + + ) : ( + {label} + )} +
+ +); + +export default StatusPill; diff --git a/client/src/app/components/nova/block/BlockTangleState.scss b/client/src/app/components/nova/block/BlockTangleState.scss index c69f8c0ab..66652ad31 100644 --- a/client/src/app/components/nova/block/BlockTangleState.scss +++ b/client/src/app/components/nova/block/BlockTangleState.scss @@ -40,41 +40,4 @@ } } } - - .block-tangle-state { - @include font-size(12px); - - display: flex; - align-items: center; - height: 24px; - margin-right: 8px; - padding: 0 8px; - border: 0; - border-radius: 6px; - outline: none; - background-color: $gray-light; - color: $gray-midnight; - font-family: $inter; - font-weight: 500; - letter-spacing: 0.5px; - white-space: nowrap; - - @include phone-down { - height: 32px; - } - - &.block-tangle-state__confirmed { - background-color: var(--message-confirmed-bg); - color: $mint-green-7; - } - - &.block-tangle-state__conflicting { - background-color: var(--message-conflicting-bg); - } - - &.block-tangle-state__pending { - background-color: var(--light-bg); - color: #8493ad; - } - } } diff --git a/client/src/app/components/nova/block/BlockTangleState.tsx b/client/src/app/components/nova/block/BlockTangleState.tsx index e8a4619e6..1d8630c6c 100644 --- a/client/src/app/components/nova/block/BlockTangleState.tsx +++ b/client/src/app/components/nova/block/BlockTangleState.tsx @@ -1,9 +1,9 @@ -import classNames from "classnames"; import React from "react"; -import Tooltip from "../../Tooltip"; -import { BlockState, u64 } from "@iota/sdk-wasm-nova/web"; -import { BlockFailureReason, BLOCK_FAILURE_REASON_STRINGS } from "@iota/sdk-wasm-nova/web/lib/types/models/block-failure-reason"; import moment from "moment"; +import { BlockState, u64 } from "@iota/sdk-wasm-nova/web"; +import { BLOCK_FAILURE_REASON_STRINGS, BlockFailureReason } from "@iota/sdk-wasm-nova/web/lib/types/models/block-failure-reason"; +import StatusPill from "~/app/components/nova/StatusPill"; +import { PillStatus } from "~/app/lib/ui/enums"; import "./BlockTangleState.scss"; export interface BlockTangleStateProps { @@ -23,38 +23,29 @@ export interface BlockTangleStateProps { failureReason?: BlockFailureReason; } +const BLOCK_STATE_TO_PILL_STATUS: Record = { + pending: PillStatus.Pending, + accepted: PillStatus.Success, + confirmed: PillStatus.Success, + finalized: PillStatus.Success, + failed: PillStatus.Error, + rejected: PillStatus.Error, +}; + const BlockTangleState: React.FC = ({ status, issuingTime, failureReason }) => { const blockIssueMoment = moment(Number(issuingTime) / 1000000); const timeReference = blockIssueMoment.fromNow(); const longTimestamp = blockIssueMoment.format("LLLL"); + const pillStatus: PillStatus = BLOCK_STATE_TO_PILL_STATUS[status]; + const failureReasonString: string | undefined = failureReason ? BLOCK_FAILURE_REASON_STRINGS[failureReason] : undefined; + return ( <>
{status && ( -
- {failureReason ? ( - - - {status} - - - ) : ( - {status} - )} -
+
{timeReference} diff --git a/client/src/app/components/nova/block/section/TransactionMetadataSection.scss b/client/src/app/components/nova/block/section/TransactionMetadataSection.scss deleted file mode 100644 index c87daebc3..000000000 --- a/client/src/app/components/nova/block/section/TransactionMetadataSection.scss +++ /dev/null @@ -1,42 +0,0 @@ -@import "../../../../../scss/fonts"; -@import "../../../../../scss/mixins"; -@import "../../../../../scss/media-queries"; -@import "../../../../../scss/variables"; -@import "../../../../../scss/themes"; - -.transaction-tangle-state { - @include font-size(12px); - - display: flex; - align-items: center; - height: 24px; - margin-right: 8px; - padding: 0 8px; - border: 0; - border-radius: 6px; - outline: none; - background-color: $gray-light; - color: $gray-midnight; - font-family: $inter; - font-weight: 500; - letter-spacing: 0.5px; - white-space: nowrap; - - @include phone-down { - height: 32px; - } - - &.transaction-tangle-state__confirmed { - background-color: var(--message-confirmed-bg); - color: $mint-green-7; - } - - &.transaction-tangle-state__conflicting { - background-color: var(--message-conflicting-bg); - } - - &.transaction-tangle-state__pending { - background-color: var(--light-bg); - color: #8493ad; - } -} diff --git a/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx b/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx index eea4c3a02..79ea81e1c 100644 --- a/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx +++ b/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx @@ -1,11 +1,11 @@ -import classNames from "classnames"; -import { TRANSACTION_FAILURE_REASON_STRINGS, Transaction, TransactionMetadata, Utils } from "@iota/sdk-wasm-nova/web"; +import { TRANSACTION_FAILURE_REASON_STRINGS, Transaction, TransactionMetadata, TransactionState, Utils } from "@iota/sdk-wasm-nova/web"; import React from "react"; -import "./TransactionMetadataSection.scss"; import Spinner from "../../../Spinner"; import TruncatedId from "~/app/components/stardust/TruncatedId"; import ContextInputView from "../../ContextInputView"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { PillStatus } from "~/app/lib/ui/enums"; +import StatusPill from "~/app/components/nova/StatusPill"; interface TransactionMetadataSectionProps { readonly transaction?: Transaction; @@ -13,8 +13,17 @@ interface TransactionMetadataSectionProps { readonly metadataError?: string; } +const TRANSACTION_STATE_TO_PILL_STATUS: Record = { + pending: PillStatus.Pending, + accepted: PillStatus.Success, + confirmed: PillStatus.Success, + finalized: PillStatus.Success, + failed: PillStatus.Error, +}; + const TransactionMetadataSection: React.FC = ({ transaction, transactionMetadata, metadataError }) => { const { name: network, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const pillStatus: PillStatus | undefined = TRANSACTION_STATE_TO_PILL_STATUS[transactionMetadata?.transactionState ?? "pending"]; return (
@@ -28,23 +37,8 @@ const TransactionMetadataSection: React.FC = ({ <>
Transaction Status
-
-
- {transactionMetadata.transactionState} -
+
+
{transactionMetadata.transactionFailureReason && ( diff --git a/client/src/app/lib/enums/index.ts b/client/src/app/lib/enums/index.ts new file mode 100644 index 000000000..877581b6e --- /dev/null +++ b/client/src/app/lib/enums/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000..3a190a546 --- /dev/null +++ b/client/src/app/lib/enums/slot-state.enums.ts @@ -0,0 +1,16 @@ +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/lib/ui/enums/index.ts b/client/src/app/lib/ui/enums/index.ts new file mode 100644 index 000000000..eeda1a054 --- /dev/null +++ b/client/src/app/lib/ui/enums/index.ts @@ -0,0 +1 @@ +export * from "./pill-status.enum"; diff --git a/client/src/app/lib/ui/enums/pill-status.enum.ts b/client/src/app/lib/ui/enums/pill-status.enum.ts new file mode 100644 index 000000000..5a3e05e32 --- /dev/null +++ b/client/src/app/lib/ui/enums/pill-status.enum.ts @@ -0,0 +1,5 @@ +export enum PillStatus { + Pending = "pending", + Success = "success", + Error = "error", +} diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index f45fbe482..62ac887e8 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -38,6 +38,7 @@ import NovaBlockPage from "./routes/nova/Block"; import NovaTransactionPage from "./routes/nova/TransactionPage"; import NovaOutputPage from "./routes/nova/OutputPage"; import NovaSearch from "./routes/nova/Search"; +import NovaSlotPage from "./routes/nova/SlotPage"; import StardustSearch from "./routes/stardust/Search"; import StardustStatisticsPage from "./routes/stardust/statistics/StatisticsPage"; import StardustTransactionPage from "./routes/stardust/TransactionPage"; @@ -179,6 +180,7 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom , , , + , , ]; diff --git a/client/src/app/routes/nova/SlotPage.scss b/client/src/app/routes/nova/SlotPage.scss new file mode 100644 index 000000000..2c802392c --- /dev/null +++ b/client/src/app/routes/nova/SlotPage.scss @@ -0,0 +1,65 @@ +@import "../../../scss/fonts"; +@import "../../../scss/mixins"; +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; + +.slot-page { + display: flex; + flex-direction: column; + + .wrapper { + display: flex; + justify-content: center; + + .inner { + display: flex; + flex: 1; + flex-direction: column; + max-width: $desktop-width; + margin: 40px 25px; + + @include desktop-down { + flex: unset; + width: 100%; + max-width: 100%; + margin: 40px 24px; + padding-right: 24px; + padding-left: 24px; + + > .row { + flex-direction: column; + } + } + + @include tablet-down { + margin: 28px 0; + } + + .slot-page--header { + display: flex; + flex-direction: column; + align-items: flex-start; + + .header--title { + margin-bottom: 8px; + } + + .header--status { + display: flex; + } + } + + .section { + padding-top: 44px; + + .section--header { + margin-top: 44px; + } + + .card--content__output { + margin-top: 20px; + } + } + } + } +} diff --git a/client/src/app/routes/nova/SlotPage.tsx b/client/src/app/routes/nova/SlotPage.tsx new file mode 100644 index 000000000..a8d6aed2e --- /dev/null +++ b/client/src/app/routes/nova/SlotPage.tsx @@ -0,0 +1,88 @@ +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 }, + }, +}: RouteComponentProps<{ + network: string; + slotIndex: string; +}>): React.JSX.Element { + 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, + }, + { + label: "RMC", + value: slotCommitment?.referenceManaCost.toString(), + }, + ]; + + function parseSlotIndex(slotIndex: string): number | undefined { + const slotIndexNum = parseInt(slotIndex, 10); + if (isNaN(slotIndexNum)) { + return; + } + return slotIndexNum; + } + + return ( +
+
+
+
+
+

Slot

+ +
+ {parsedSlotIndex && ( +
+ +
+ )} +
+ {parsedSlotIndex ? ( +
+
+
+

General

+
+
+ {dataRows.map((dataRow, index) => { + if (dataRow.value || dataRow.truncatedId) { + return ; + } + })} +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/client/src/assets/modals/nova/slot/main-header.json b/client/src/assets/modals/nova/slot/main-header.json new file mode 100644 index 000000000..c9c5f0a80 --- /dev/null +++ b/client/src/assets/modals/nova/slot/main-header.json @@ -0,0 +1,11 @@ +{ + "title": "Slot", + "description": "

Each block in IOTA 2.0 contains a commitment to the content of a certain slot in the past. A slot commitment is a hash value that encapsulates all the crucial information about a slot (such as accepted blocks and transactions, the index of the slot, etc.).

", + "links": [ + { + "label": "Read more", + "href": "https://wiki.iota.org/learn/protocols/iota2.0/core-concepts/consensus/preliminaries/#slot-commitment-chain", + "isExternal": true + } + ] +} diff --git a/client/src/helpers/nova/hooks/useSlotDetails.ts b/client/src/helpers/nova/hooks/useSlotDetails.ts new file mode 100644 index 000000000..45da0620f --- /dev/null +++ b/client/src/helpers/nova/hooks/useSlotDetails.ts @@ -0,0 +1,54 @@ +import { SlotCommitment } from "@iota/sdk-wasm-nova/web"; +import { plainToInstance } from "class-transformer"; +import { useEffect, useState } from "react"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { useIsMounted } from "~/helpers/hooks/useIsMounted"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +interface IUseSlotDetails { + slotCommitment: SlotCommitment | null; + error: string | undefined; + isLoading: boolean; +} + +export default function useSlotDetails(network: string, slotIndex: string): IUseSlotDetails { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [slotCommitment, setSlotCommitment] = useState(null); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + setSlotCommitment(null); + if (!slotCommitment) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .getSlotCommitment({ + network, + slotIndex, + }) + .then((response) => { + if (isMounted) { + const slot = plainToInstance(SlotCommitment, response.slot) as unknown as SlotCommitment; + setSlotCommitment(slot); + setError(response.error); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, slotIndex]); + + return { + slotCommitment, + error, + isLoading, + }; +} diff --git a/client/src/models/api/nova/ISlotRequest.ts b/client/src/models/api/nova/ISlotRequest.ts new file mode 100644 index 000000000..b00482593 --- /dev/null +++ b/client/src/models/api/nova/ISlotRequest.ts @@ -0,0 +1,11 @@ +export interface ISlotRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The slot index to get the commitment for. + */ + slotIndex: string; +} diff --git a/client/src/models/api/nova/ISlotResponse.ts b/client/src/models/api/nova/ISlotResponse.ts new file mode 100644 index 000000000..dc7e83cb4 --- /dev/null +++ b/client/src/models/api/nova/ISlotResponse.ts @@ -0,0 +1,6 @@ +import { SlotCommitment } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "../IResponse"; + +export interface ISlotResponse extends IResponse { + slot: SlotCommitment; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 5c03dbf0b..bff44cf77 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -29,6 +29,8 @@ import { IAddressDetailsRequest } from "~/models/api/nova/address/IAddressDetail import { IAddressDetailsResponse } from "~/models/api/nova/address/IAddressDetailsResponse"; import { IFoundriesResponse } from "~/models/api/nova/foundry/IFoundriesResponse"; import { IFoundriesRequest } from "~/models/api/nova/foundry/IFoundriesRequest"; +import { ISlotRequest } from "~/models/api/nova/ISlotRequest"; +import { ISlotResponse } from "~/models/api/nova/ISlotResponse"; import { ITransactionDetailsRequest } from "~/models/api/nova/ITransactionDetailsRequest"; import { ITransactionDetailsResponse } from "~/models/api/nova/ITransactionDetailsResponse"; import { ICongestionRequest } from "~/models/api/nova/ICongestionRequest"; @@ -177,6 +179,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/output/rewards/${request.network}/${request.outputId}`, "get"); } + /** + * Get the slot commitment. + * @param request The request to send. + * @returns The response from the request. + */ + public async getSlotCommitment(request: ISlotRequest): Promise { + return this.callApi(`nova/slot/${request.network}/${request.slotIndex}`, "get"); + } + /** * Get the stats. * @param request The request to send. From a03f72ab416c781bfd517bd017171380792f6ce4 Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 26 Feb 2024 16:47:54 +0100 Subject: [PATCH 3/4] 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. From ae55f703b2cc04d82612d4537f91f1d08bfdee98 Mon Sep 17 00:00:00 2001 From: Bran <52735957+brancoder@users.noreply.github.com> Date: Mon, 26 Feb 2024 16:53:37 +0100 Subject: [PATCH 4/4] feat: add search by delegation id (#1176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Begoña Álvarez de la Cruz --- api/src/services/nova/novaApiService.ts | 19 +++++++++++++++++++ api/src/utils/nova/searchExecutor.ts | 15 +++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 9290527c2..441c1b520 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -184,6 +184,25 @@ export class NovaApiService { } } + /** + * Get the delegation output details. + * @param delegationId The delegationId to get the output details for. + * @returns The delegation output details. + */ + public async delegationDetails(delegationId: string): Promise { + try { + const delegationOutputId = await this.client.delegationOutputId(delegationId); + + if (delegationOutputId) { + const outputResponse = await this.outputDetails(delegationOutputId); + + return outputResponse.error ? { error: outputResponse.error } : { output: outputResponse.output }; + } + } catch { + return { message: "Delegation output not found" }; + } + } + /** * Get controlled Foundry output id by controller Account address * @param accountAddress The bech32 account address to get the controlled Foundries for. diff --git a/api/src/utils/nova/searchExecutor.ts b/api/src/utils/nova/searchExecutor.ts index bb3785fc8..e2dad915e 100644 --- a/api/src/utils/nova/searchExecutor.ts +++ b/api/src/utils/nova/searchExecutor.ts @@ -97,6 +97,21 @@ export class SearchExecutor { ); } + if (searchQuery.delegationId) { + promises.push( + this.executeQuery( + this.apiService.delegationDetails(searchQuery.delegationId), + (response) => { + promisesResult = { + output: response.output, + error: response.error || response.message, + }; + }, + "Delegation id fetch failed", + ), + ); + } + if (searchQuery.transactionId) { promises.push( this.executeQuery(