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.