diff --git a/api/src/models/api/nova/IOutputDetailsResponse.ts b/api/src/models/api/nova/IOutputDetailsResponse.ts new file mode 100644 index 000000000..abe29fde2 --- /dev/null +++ b/api/src/models/api/nova/IOutputDetailsResponse.ts @@ -0,0 +1,9 @@ +import { OutputResponse } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface IOutputDetailsResponse extends IResponse { + /** + * The output data. + */ + output?: OutputResponse; +} diff --git a/api/src/models/api/nova/IResponse.ts b/api/src/models/api/nova/IResponse.ts new file mode 100644 index 000000000..285ca58b9 --- /dev/null +++ b/api/src/models/api/nova/IResponse.ts @@ -0,0 +1,11 @@ +export interface IResponse { + /** + * An error for the response. + */ + error?: string; + + /** + * A message for the response. + */ + message?: string; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index d90417965..b8ff7ced3 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -142,5 +142,7 @@ export const routes: IRoute[] = [ { path: "/stardust/token/distribution/:network", method: "get", folder: "stardust/address/distribution", func: "get" - } + }, + // Nova + { path: "/nova/output/:network/:outputId", method: "get", folder: "nova/output", func: "get" } ]; diff --git a/api/src/routes/nova/output/get.ts b/api/src/routes/nova/output/get.ts new file mode 100644 index 000000000..8dd204880 --- /dev/null +++ b/api/src/routes/nova/output/get.ts @@ -0,0 +1,32 @@ +import { ServiceFactory } from "../../../factories/serviceFactory"; +import { IOutputDetailsRequest } from "../../../models/api/chrysalis/IOutputDetailsRequest"; +import { IOutputDetailsResponse } from "../../../models/api/nova/IOutputDetailsResponse"; +import { IConfiguration } from "../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../models/db/protocolVersion"; +import { NetworkService } from "../../../services/networkService"; +import { NovaApi } from "../../../services/nova/novaApi"; +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: IOutputDetailsRequest +): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.outputId, "outputId"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + return NovaApi.outputDetails(networkConfig, request.outputId); +} diff --git a/api/src/services/nova/novaApi.ts b/api/src/services/nova/novaApi.ts new file mode 100644 index 000000000..87f424381 --- /dev/null +++ b/api/src/services/nova/novaApi.ts @@ -0,0 +1,67 @@ +import { + __ClientMethods__, OutputResponse, Client +} from "@iota/sdk-nova"; +import { ServiceFactory } from "../../factories/serviceFactory"; +import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; +import { INetwork } from "../../models/db/INetwork"; + +type NameType = T extends { name: infer U } ? U : never; +type ExtractedMethodNames = NameType<__ClientMethods__>; + +/** + * Class to interact with the nova API. + */ +export class NovaApi { + /** + * Get the output details. + * @param network The network to find the items on. + * @param outputId The output id to get the details. + * @returns The item details. + */ + public static async outputDetails(network: INetwork, outputId: string): Promise { + const outputResponse = await this.tryFetchNodeThenPermanode( + outputId, + "getOutput", + network + ); + + return outputResponse ? + { output: outputResponse } : + { message: "Output not found" }; + } + + /** + * Generic helper function to try fetching from node client. + * On failure (or not present), we try to fetch from permanode (if configured). + * @param args The argument(s) to pass to the fetch calls. + * @param methodName The function to call on the client. + * @param network The network config in context. + * @returns The results or null if call(s) failed. + */ + public static async tryFetchNodeThenPermanode( + args: A, + methodName: ExtractedMethodNames, + network: INetwork + ): Promise | null { + const { permaNodeEndpoint, disableApiFallback } = network; + const isFallbackEnabled = !disableApiFallback; + const client = ServiceFactory.get(`client-${network.network}`); + + try { + // try fetch from node + const result: Promise = client[methodName](args); + return await result; + } catch { } + + if (permaNodeEndpoint && isFallbackEnabled) { + const permanodeClient = ServiceFactory.get(`permanode-client-${network.network}`); + try { + // try fetch from permanode (chronicle) + const result: Promise = permanodeClient[methodName](args); + return await result; + } catch { } + } + + return null; + } +} diff --git a/client/package.json b/client/package.json index 9edd434bc..dbd873e5c 100644 --- a/client/package.json +++ b/client/package.json @@ -114,5 +114,8 @@ "not dead", "not ie <= 11", "not op_mini all" - ] + ], + "prettier": { + "tabWidth": 4 + } } diff --git a/client/src/app/components/nova/AddressView.tsx b/client/src/app/components/nova/AddressView.tsx new file mode 100644 index 000000000..fefd76b22 --- /dev/null +++ b/client/src/app/components/nova/AddressView.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Address, AddressType } from "@iota/sdk-wasm-nova/web"; + +interface AddressViewProps { + address: Address; +} + +const AddressView: React.FC = ({ address }) => { + return ( +
+
+ {getAddressTypeName(address.type)} +
+
+ {JSON.stringify(address)} +
+
+ ); +}; + +function getAddressTypeName(type: AddressType): string { + switch (type) { + case AddressType.Ed25519: + return "Ed25519"; + case AddressType.Account: + return "Account"; + case AddressType.Nft: + return "Nft"; + case AddressType.Anchor: + return "Anchor"; + case AddressType.ImplicitAccountCreation: + return "ImplicitAccountCreation"; + case AddressType.Multi: + return "Multi"; + case AddressType.Restricted: + return "Restricted"; + } +} + +export default AddressView; diff --git a/client/src/app/components/nova/OutputView.scss b/client/src/app/components/nova/OutputView.scss new file mode 100644 index 000000000..cc500bef7 --- /dev/null +++ b/client/src/app/components/nova/OutputView.scss @@ -0,0 +1,84 @@ +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; + +.card--content__output { + padding: 0 30px; + margin-bottom: 20px; + + @include phone-down { + padding: 0 4px; + } + + .card--value.card-header--wrapper { + width: 100%; + display: flex; + margin-bottom: 0px; + height: 32px; + align-items: center; + + .output-header { + display: flex; + width: 100%; + + .output-type--name { + white-space: nowrap; + } + + .output-id--link { + display: flex; + margin-right: 2px; + white-space: nowrap; + + a { + margin-right: 0px; + } + + .highlight { + font-weight: 500; + color: $gray-6; + margin-left: 2px; + } + + .copy-button { + margin-left: 2px; + } + } + } + + .amount-size { + width: min-content; + text-align: end; + word-break: normal; + white-space: nowrap; + cursor: pointer; + margin-bottom: 0; + margin-right: 4px; + + span { + word-break: keep-all; + } + } + } + + .left-border { + border-left: 1px solid var(--border-color); + } +} + +.card--content--dropdown { + margin-right: 8px; + cursor: pointer; + + svg { + transition: transform 0.25s ease; + + path { + fill: var(--card-color); + } + } + + &.opened > svg { + transform: rotate(90deg); + } +} + diff --git a/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx new file mode 100644 index 000000000..2a2b696cf --- /dev/null +++ b/client/src/app/components/nova/OutputView.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import DropdownIcon from "~assets/dropdown-arrow.svg?react"; +import classNames from "classnames"; +import { Output, OutputType, CommonOutput } from "@iota/sdk-wasm-nova/web"; +import UnlockConditionView from "./UnlockConditionView"; +import CopyButton from "../CopyButton"; +import { Link } from "react-router-dom"; +import "./OutputView.scss"; + +interface OutputViewProps { + outputId: string; + output: Output; + showCopyAmount: boolean; +} + +const OutputView: React.FC = ({ + outputId, + output, + showCopyAmount, +}) => { + const [isExpanded, setIsExpanded] = React.useState(false); + const [isFormattedBalance, setIsFormattedBalance] = React.useState(true); + + console.log(output); + + const outputIdTransactionPart = `${outputId.slice(0, 8)}....${outputId.slice(-8, -4)}`; + const outputIdIndexPart = outputId.slice(-4); + + return ( +
+
setIsExpanded(!isExpanded)} + className="card--value card-header--wrapper" + > +
+ +
+
+ +
+ ( + + {outputIdTransactionPart} + + {outputIdIndexPart} + + + ) + +
+
+ {showCopyAmount && ( +
+ { + setIsFormattedBalance(!isFormattedBalance); + e.stopPropagation(); + }} + > + {output.amount} + +
+ )} + {showCopyAmount && } +
+ {isExpanded && ( +
+ {(output as CommonOutput).unlockConditions?.map( + (unlockCondition, idx) => ( + + ), + )} +
+ )} +
+ ); +}; + +function getOutputTypeName(type: OutputType): string { + switch (type) { + case OutputType.Basic: + return "Basic"; + case OutputType.Account: + return "Account"; + case OutputType.Anchor: + return "Anchor"; + case OutputType.Foundry: + return "Foundry"; + case OutputType.Nft: + return "Nft"; + case OutputType.Delegation: + return "Delegation"; + } +} + +export default OutputView; diff --git a/client/src/app/components/nova/UnlockConditionView.tsx b/client/src/app/components/nova/UnlockConditionView.tsx new file mode 100644 index 000000000..0a3bbf87a --- /dev/null +++ b/client/src/app/components/nova/UnlockConditionView.tsx @@ -0,0 +1,184 @@ +import { + AddressUnlockCondition, + ExpirationUnlockCondition, + GovernorAddressUnlockCondition, + StateControllerAddressUnlockCondition, + StorageDepositReturnUnlockCondition, + TimelockUnlockCondition, + UnlockConditionType, + UnlockCondition, + ImmutableAccountAddressUnlockCondition, +} from "@iota/sdk-wasm-nova/web"; +import classNames from "classnames"; +import React from "react"; +import DropdownIcon from "~assets/dropdown-arrow.svg?react"; +import AddressView from "./AddressView"; + +interface UnlockConditionViewProps { + unlockCondition: UnlockCondition; + isPreExpanded?: boolean; +} + +const UnlockConditionView: React.FC = ({ + unlockCondition, + isPreExpanded, +}) => { + const [isFormattedBalance, setIsFormattedBalance] = React.useState(true); + const [isExpanded, setIsExpanded] = React.useState(isPreExpanded ?? false); + + return ( +
+
setIsExpanded(!isExpanded)} + > +
+ +
+
+ {getUnlockConditionTypeName(unlockCondition.type)} +
+
+ {isExpanded && ( +
+ {unlockCondition.type === UnlockConditionType.Address && ( + + )} + {unlockCondition.type === + UnlockConditionType.StorageDepositReturn && ( + +
Return address
+ +
Amount:
+
+ + setIsFormattedBalance( + !isFormattedBalance, + ) + } + > + {Number( + ( + unlockCondition as StorageDepositReturnUnlockCondition + ).amount, + )} + +
+
+ )} + {unlockCondition.type === UnlockConditionType.Timelock && + (unlockCondition as TimelockUnlockCondition) + .slotIndex && ( + +
Slot index
+
+ { + ( + unlockCondition as TimelockUnlockCondition + ).slotIndex + } +
+
+ )} + {unlockCondition.type === + UnlockConditionType.Expiration && ( + + + {(unlockCondition as ExpirationUnlockCondition) + .slotIndex && ( + +
+ Slot index +
+
+ { + ( + unlockCondition as ExpirationUnlockCondition + ).slotIndex + } +
+
+ )} +
+ )} + {unlockCondition.type === + UnlockConditionType.GovernorAddress && ( + + )} + {unlockCondition.type === + UnlockConditionType.ImmutableAccountAddress && ( + + )} + {unlockCondition.type === + UnlockConditionType.StateControllerAddress && ( + + )} +
+ )} +
+ ); +}; + +function getUnlockConditionTypeName(type: UnlockConditionType): string { + switch (type) { + case UnlockConditionType.Address: + return "Address"; + case UnlockConditionType.StorageDepositReturn: + return "Storage deposit return"; + case UnlockConditionType.Timelock: + return "Timelock"; + case UnlockConditionType.Expiration: + return "Expiration"; + case UnlockConditionType.GovernorAddress: + return "Governor address"; + case UnlockConditionType.StateControllerAddress: + return "State controller address"; + case UnlockConditionType.ImmutableAccountAddress: + return "Immutable account address"; + } +} + +export default UnlockConditionView; diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index d5ae92552..61ef11b54 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -32,6 +32,7 @@ import { Landing as StardustLanding } from "./routes/stardust/landing/Landing"; import NftRedirectRoute from "./routes/stardust/NftRedirectRoute"; import StardustOutputList from "./routes/stardust/OutputList"; import StardustOutputPage from "./routes/stardust/OutputPage"; +import NovaOutputPage from "./routes/nova/OutputPage"; import StardustSearch from "./routes/stardust/Search"; import StardustStatisticsPage from "./routes/stardust/statistics/StatisticsPage"; import StardustTransactionPage from "./routes/stardust/TransactionPage"; @@ -213,7 +214,11 @@ const buildAppRoutes = ( + />, + , ]; return ( diff --git a/client/src/app/routes/nova/OutputPage.scss b/client/src/app/routes/nova/OutputPage.scss new file mode 100644 index 000000000..b830e2092 --- /dev/null +++ b/client/src/app/routes/nova/OutputPage.scss @@ -0,0 +1,86 @@ +@import "../../../scss/fonts"; +@import "../../../scss/mixins"; +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; + +.output-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; + } + + .ouput-page--header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .section { + padding-top: 44px; + + .section--header { + margin-top: 44px; + } + + .card--content__output { + margin-top: 20px; + } + } + } + } + + .tooltip { + .children { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + .tooltip__special { + background-color: #FFF4DF; + border-radius: 4px; + padding: 0 4px; + font-weight: 400; + } + + .wrap { + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + width: 170px; + + .arrow { + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + } + } + } +} diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx new file mode 100644 index 000000000..964b345a2 --- /dev/null +++ b/client/src/app/routes/nova/OutputPage.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import mainMessage from "~assets/modals/stardust/output/main-header.json"; +import { Link, RouteComponentProps } from "react-router-dom"; +import Modal from "~/app/components/Modal"; +import NotFound from "~/app/components/NotFound"; +import OutputView from "~/app/components/nova/OutputView"; +import { useOutputDetails } from "~/helpers/nova/hooks/useOutputDetails"; +import CopyButton from "~/app/components/CopyButton"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import "./OutputPage.scss"; + +interface OutputPageProps { + /** + * The network to lookup. + */ + network: string; + + /** + * The output id to lookup. + */ + outputId: string; +} + +const OutputPage: React.FC> = ({ + match: { + params: { network, outputId }, + }, +}) => { + const { output, outputMetadataResponse, error } = useOutputDetails( + network, + outputId, + ); + + if (error) { + return ( +
+
+
+
+
+

Output

+
+
+ +
+
+
+ ); + } + + const { blockId, transactionId, outputIndex, isSpent, transactionIdSpent } = + outputMetadataResponse ?? {}; + + return ( + (output && ( +
+
+
+
+
+

Output

+ +
+
+
+
+ +
+ +
+
+

Metadata

+
+
+ + {blockId && ( +
+
Block ID
+
+ + {blockId} + + +
+
+ )} + + {transactionId && ( +
+
Transaction ID
+
+ +
+
+ )} + + {outputIndex !== undefined && ( +
+
Output index
+
+ + {outputIndex} + +
+
+ )} + + {isSpent !== undefined && ( +
+
Is spent ?
+
+ + {isSpent.toString()} + +
+
+ )} + + {transactionIdSpent && ( +
+
+ Spent in transaction with ID +
+
+ + {transactionIdSpent} + + +
+
+ )} +
+
+
+
+ )) ?? + null + ); +}; + +export default OutputPage; diff --git a/client/src/helpers/nova/hooks/useOutputDetails.ts b/client/src/helpers/nova/hooks/useOutputDetails.ts new file mode 100644 index 000000000..bad4d1fd6 --- /dev/null +++ b/client/src/helpers/nova/hooks/useOutputDetails.ts @@ -0,0 +1,65 @@ +import { IOutputMetadataResponse, Output } from "@iota/sdk-wasm-nova/web"; +import { useEffect, useState } from "react"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { useIsMounted } from "~/helpers/hooks/useIsMounted"; +import { HexHelper } from "~/helpers/stardust/hexHelper"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +/** + * Fetch output details + * @param network The Network in context + * @param outputId The output id + * @returns The output, metadata, loading bool and error message. + */ +export function useOutputDetails( + network: string, + outputId: string | null, +): { + output: Output | null; + outputMetadataResponse: IOutputMetadataResponse | null; + isLoading: boolean; + error: string | null; +} { + const isMounted = useIsMounted(); + const [apiClient] = useState( + ServiceFactory.get(`api-client-${NOVA}`), + ); + const [output, setOutput] = useState(null); + const [metadata, setMetadata] = useState( + null, + ); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + setOutput(null); + setMetadata(null); + if (outputId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .outputDetails({ + network, + outputId: HexHelper.addPrefix(outputId), + }) + .then((response) => { + if (isMounted) { + const details = response.output; + setOutput(details?.output ?? null); + setMetadata(details?.metadata ?? null); + setError(response.error ?? null); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, outputId]); + + return { output, outputMetadataResponse: metadata, isLoading, error }; +} diff --git a/client/src/models/api/nova/IOutputDetailsResponse.ts b/client/src/models/api/nova/IOutputDetailsResponse.ts new file mode 100644 index 000000000..5b749c3e1 --- /dev/null +++ b/client/src/models/api/nova/IOutputDetailsResponse.ts @@ -0,0 +1,9 @@ +import { OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; + +export interface IOutputDetailsResponse extends IResponse { + /** + * The output data. + */ + output?: OutputResponse; +} diff --git a/client/src/models/api/nova/IResponse.ts b/client/src/models/api/nova/IResponse.ts new file mode 100644 index 000000000..285ca58b9 --- /dev/null +++ b/client/src/models/api/nova/IResponse.ts @@ -0,0 +1,11 @@ +export interface IResponse { + /** + * An error for the response. + */ + error?: string; + + /** + * A message for the response. + */ + message?: string; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index b318c0b19..ee72a84a4 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -1,5 +1,7 @@ import { INetworkBoundGetRequest } from "~/models/api/INetworkBoundGetRequest"; +import { IOutputDetailsRequest } from "~/models/api/IOutputDetailsRequest"; import { INodeInfoResponse } from "~/models/api/nova/INodeInfoResponse"; +import { IOutputDetailsResponse } from "~/models/api/nova/IOutputDetailsResponse"; import { ApiClient } from "../apiClient"; /** @@ -17,4 +19,15 @@ export class NovaApiClient extends ApiClient { "get" ); } + + /** + * Get the output details. + * @param request The request to send. + * @returns The response from the request. + */ + public async outputDetails(request: IOutputDetailsRequest): Promise { + return this.callApi( + `nova/output/${request.network}/${request.outputId}`, "get" + ); + } }