diff --git a/api/src/utils/nova/searchExecutor.ts b/api/src/utils/nova/searchExecutor.ts index e2dad915e..39460b59a 100644 --- a/api/src/utils/nova/searchExecutor.ts +++ b/api/src/utils/nova/searchExecutor.ts @@ -127,6 +127,21 @@ export class SearchExecutor { ); } + if (searchQuery.foundryId) { + promises.push( + this.executeQuery( + this.apiService.foundryDetails(searchQuery.foundryId), + (response) => { + promisesResult = { + foundryId: response.foundryDetails ? searchQuery.foundryId : undefined, + error: response.error || response.message, + }; + }, + "Foundry details fetch failed", + ), + ); + } + await Promise.any(promises).catch((_) => {}); if (promisesResult !== null) { diff --git a/client/src/app/components/nova/address/FeaturesSection.tsx b/client/src/app/components/nova/address/FeaturesSection.tsx new file mode 100644 index 000000000..c885b604d --- /dev/null +++ b/client/src/app/components/nova/address/FeaturesSection.tsx @@ -0,0 +1,46 @@ +import { AccountOutput, FoundryOutput, NftOutput } from "@iota/sdk-wasm-nova/web"; +import { optional } from "@ruffy/ts-optional"; +import React from "react"; +import FeatureView from "../FeaturesView"; + +interface FeaturesSectionProps { + /** + * The Output + */ + readonly output?: NftOutput | AccountOutput | FoundryOutput; +} + +const FeaturesSection: React.FC = ({ output }) => ( + + {optional(output?.features).nonEmpty() && ( +
+
+
+

Features

+
+
+ {output?.features?.map((feature, idx) => ( + + ))} +
+ )} + {optional(output?.immutableFeatures).nonEmpty() && ( +
+
+
+

Immutable features

+
+
+ {output?.immutableFeatures?.map((feature, idx) => ( + + ))} +
+ )} +
+); + +FeaturesSection.defaultProps = { + output: undefined, +}; + +export default FeaturesSection; diff --git a/client/src/app/components/nova/foundry/TokenInfoSection.scss b/client/src/app/components/nova/foundry/TokenInfoSection.scss new file mode 100644 index 000000000..8b649ed21 --- /dev/null +++ b/client/src/app/components/nova/foundry/TokenInfoSection.scss @@ -0,0 +1,36 @@ +@import "../../../../scss/fonts"; +@import "../../../../scss/mixins"; +@import "../../../../scss/media-queries"; +@import "../../../../scss/variables"; + +.token-info { + display: flex; + flex-direction: column; + + .token-metadata__logo { + width: 32px; + height: 32px; + } + .section .token-metadata .section--data:last-child { + margin-bottom: 26px; + } + + .token-metadata__symbol { + 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; + background-color: var(--light-bg); + color: $gray; + } +} diff --git a/client/src/app/components/nova/foundry/TokenInfoSection.tsx b/client/src/app/components/nova/foundry/TokenInfoSection.tsx new file mode 100644 index 000000000..4a0b59a4d --- /dev/null +++ b/client/src/app/components/nova/foundry/TokenInfoSection.tsx @@ -0,0 +1,111 @@ +import { SimpleTokenScheme, TokenScheme, TokenSchemeType } from "@iota/sdk-wasm-nova/web"; +import React from "react"; +import { useTokenRegistryNativeTokenCheck } from "~helpers/stardust/hooks/useTokenRegistryNativeTokenCheck"; +import { formatNumberWithCommas } from "~helpers/stardust/valueFormatHelper"; +import { ITokenMetadata } from "~models/api/stardust/foundry/ITokenMetadata"; +import "./TokenInfoSection.scss"; + +interface TokenInfoSectionProps { + /** + * The token id. + */ + readonly tokenId: string; + /** + * The token scheme for the foundry. + */ + readonly tokenScheme: TokenScheme; + /** + * The IRC standard metadata. + */ + readonly tokenMetadata?: ITokenMetadata | null; +} + +const TokenInfoSection: React.FC = ({ tokenId, tokenScheme, tokenMetadata }) => { + const [isWhitelisted] = useTokenRegistryNativeTokenCheck(tokenId); + + if (tokenScheme.type !== TokenSchemeType.Simple) { + return null; + } + + const simpleTokenScheme = tokenScheme as SimpleTokenScheme; + + const maximumSupply = formatNumberWithCommas(BigInt(simpleTokenScheme.maximumSupply)); + const mintedTokens = formatNumberWithCommas(BigInt(simpleTokenScheme.mintedTokens)); + const meltedTokens = formatNumberWithCommas(BigInt(simpleTokenScheme.meltedTokens)); + + return ( +
+
+ {tokenMetadata && isWhitelisted && ( +
+
+
Name
+
+ {tokenMetadata.logoUrl && ( + + {tokenMetadata.name} + + )} + {tokenMetadata.name} + {tokenMetadata.symbol} +
+
+ {tokenMetadata.description && ( +
+
Description
+
+ {tokenMetadata.description} +
+
+ )} + {tokenMetadata.logoUrl && ( +
+
Resources
+ +
+ )} +
+ )} +
+
Token scheme
+
+ {tokenScheme.type} +
+
+
+
Maximum supply
+
+ {maximumSupply} +
+
+
+
Minted tokens
+
+ {mintedTokens} +
+
+
+
Melted tokens
+
+ {meltedTokens} +
+
+
+
+ ); +}; +TokenInfoSection.defaultProps = { + tokenMetadata: undefined, +}; + +export default TokenInfoSection; diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 236d2916d..d083045ac 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -37,6 +37,7 @@ 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 NovaFoundryPage from "./routes/nova/FoundryPage"; import NovaSearch from "./routes/nova/Search"; import NovaSlotPage from "./routes/nova/SlotPage"; import StardustSearch from "./routes/stardust/Search"; @@ -182,6 +183,7 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom , , , + , ]; return ( diff --git a/client/src/app/routes/nova/FoundryPage.scss b/client/src/app/routes/nova/FoundryPage.scss new file mode 100644 index 000000000..a2d9cfd68 --- /dev/null +++ b/client/src/app/routes/nova/FoundryPage.scss @@ -0,0 +1,67 @@ +@import "../../../scss/fonts"; +@import "../../../scss/mixins"; +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; + +.foundry { + 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; + } + + .foundry--header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .balance { + margin-left: 16px; + } + + .feature-block { + .card--label { + @include font-size(12px); + + display: flex; + align-items: center; + height: 32px; + color: var(--card-color); + font-family: $inter; + font-weight: 500; + letter-spacing: 0.5px; + white-space: nowrap; + } + .data-toggle { + margin-top: 12px; + } + } + } + } +} diff --git a/client/src/app/routes/nova/FoundryPage.tsx b/client/src/app/routes/nova/FoundryPage.tsx new file mode 100644 index 000000000..2d2d412a4 --- /dev/null +++ b/client/src/app/routes/nova/FoundryPage.tsx @@ -0,0 +1,185 @@ +import { + AccountAddress, + FeatureType, + FoundryOutput, + MetadataFeature, + ImmutableAccountAddressUnlockCondition, + Utils, + Bech32Address, +} from "@iota/sdk-wasm-nova/web"; +import React, { useEffect, useState } from "react"; +import { RouteComponentProps } from "react-router"; +import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; +import foundryMainHeaderInfo from "~assets/modals/stardust/foundry/main-header.json"; +import tokenSchemeIRC30 from "~assets/schemas/token-schema-IRC30.json"; +import { useFoundryDetails } from "~helpers/nova/hooks/useFoundryDetails"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import { isMarketedNetwork } from "~helpers/networkHelper"; +import { tryParseMetadata } from "~helpers/stardust/metadataUtils"; +import { formatAmount } from "~helpers/stardust/valueFormatHelper"; +import { ITokenMetadata } from "~models/api/stardust/foundry/ITokenMetadata"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import Icon from "~/app/components/Icon"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import NotFound from "~/app/components/NotFound"; +import Modal from "~/app/components/Modal"; +import Spinner from "~/app/components/Spinner"; +import AssetsTable from "~/app/components/nova/address/section/native-tokens/AssetsTable"; +import FeaturesSection from "~/app/components/nova/address/FeaturesSection"; +import FiatValue from "~/app/components/FiatValue"; +import TabbedSection from "~/app/components/hoc/TabbedSection"; +import TokenInfoSection from "~/app/components/nova/foundry/TokenInfoSection"; +import "./FoundryPage.scss"; + +export interface FoundryPageProps { + /** + * The network to lookup. + */ + network: string; + + /** + * The foundry id to lookup. + */ + foundryId: string; +} + +enum FOUNDRY_PAGE_TABS { + TokenInfo = "Token Info", + Features = "Features", + NativeTokens = "Native Tokens", +} + +const FoundryPage: React.FC> = ({ + match: { + params: { network, foundryId }, + }, +}) => { + const isMounted = useIsMounted(); + const { tokenInfo, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const [isFormattedBalance, setIsFormattedBalance] = useState(true); + const [foundryDetails, isFoundryDetailsLoading, foundryError] = useFoundryDetails(network, foundryId); + const [foundryOutput, setFoundryOutput] = useState(); + const [controllerAccountId, setControllerAccountId] = useState(); + const [controllerAccountBech32, setControllerAccountBech32] = useState(); + const [tokenMetadata, setTokenMetadata] = useState(); + const [tokensCount, setTokensCount] = useState(0); + + useEffect(() => { + if (foundryDetails) { + const output = foundryDetails?.output as FoundryOutput; + const immutableAccountUnlockCondition = output.unlockConditions[0] as ImmutableAccountAddressUnlockCondition; + const accountId = (immutableAccountUnlockCondition.address as AccountAddress).accountId; + const bech32 = Utils.addressToBech32(immutableAccountUnlockCondition.address, bech32Hrp); + + const immutableFeatures = (foundryDetails?.output as FoundryOutput).immutableFeatures; + const metadataFeature = immutableFeatures?.find((feature) => feature.type === FeatureType.Metadata) as MetadataFeature; + + if (isMounted && metadataFeature) { + const parsedMetadata = tryParseMetadata( + metadataFeature.entries[Object.keys(metadataFeature.entries)[0]], + tokenSchemeIRC30, + ); + setTokenMetadata(parsedMetadata); + } + if (isMounted) { + setFoundryOutput(output); + setControllerAccountId(accountId); + setControllerAccountBech32(bech32); + } + } + }, [foundryDetails]); + + let foundryContent = null; + if (foundryDetails && foundryOutput) { + const isMarketed = isMarketedNetwork(network); + const serialNumber = foundryOutput.serialNumber; + const balance = Number(foundryOutput.amount); + + foundryContent = ( + +
+
+
+

General

+
+
+
+
Serial number
+
+ {serialNumber} +
+
+ {controllerAccountId && controllerAccountBech32 && ( +
+
Controller Account
+
+ +
+
+ )} +
+
+ +
+
Balance
+
+ {balance && ( + + setIsFormattedBalance(!isFormattedBalance)} className="pointer margin-r-5"> + {formatAmount(balance, tokenInfo, !isFormattedBalance)} + + {isMarketed && ( + + {" "} + ( + + ) + + )} + + )} +
+
+
+
+
+ + + + + +
+ ); + } + + return ( +
+
+
+
+
+

Foundry

+ + {isFoundryDetailsLoading && } +
+
+ {foundryError ? : foundryContent} +
+
+
+ ); +}; + +export default FoundryPage;