From a52870448a5618ec3f6354aa77dd6958f9504270 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Tue, 13 Feb 2024 19:38:32 +0100 Subject: [PATCH 01/31] feat: add output tab to address page --- .../nova/address/AccountAddressView.tsx | 26 +++++--- .../nova/address/AnchorAddressView.tsx | 27 +++++--- .../nova/address/Ed25519AddressView.tsx | 28 +++++--- .../ImplicitAccountCreationAddressView.tsx | 28 +++++--- .../nova/address/NftAddressView.tsx | 27 +++++--- .../section/AddressPageTabbedSections.tsx | 66 +++++++++++++++++++ 6 files changed, 159 insertions(+), 43 deletions(-) create mode 100644 client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx diff --git a/client/src/app/components/nova/address/AccountAddressView.tsx b/client/src/app/components/nova/address/AccountAddressView.tsx index 9ef70952f..043838f47 100644 --- a/client/src/app/components/nova/address/AccountAddressView.tsx +++ b/client/src/app/components/nova/address/AccountAddressView.tsx @@ -1,17 +1,26 @@ import { AccountAddress } from "@iota/sdk-wasm-nova/web"; -import React from "react"; +import React, { Reducer, useReducer } from "react"; import { useAccountAddressState } from "~/helpers/nova/hooks/useAccountAddressState"; import Spinner from "../../Spinner"; import Bech32Address from "../../nova/address/Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; interface AccountAddressViewProps { accountAddress: AccountAddress; } +export interface IAccountAddressViewState { + isAssociatedOutputsLoading: boolean; +} + const AccountAddressView: React.FC = ({ accountAddress }) => { const { accountAddressDetails, isAccountDetailsLoading } = useAccountAddressState(accountAddress); - const isPageLoading = isAccountDetailsLoading; + const [state, setState] = useReducer>>( + (currentState, newState) => ({ ...currentState, ...newState }), + { isAssociatedOutputsLoading: false }, + ); + const { isAssociatedOutputsLoading } = state; + const isPageLoading = isAccountDetailsLoading || isAssociatedOutputsLoading; return (
@@ -36,12 +45,11 @@ const AccountAddressView: React.FC = ({ accountAddress
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + /> )} diff --git a/client/src/app/components/nova/address/AnchorAddressView.tsx b/client/src/app/components/nova/address/AnchorAddressView.tsx index 6a0b2b161..a9d9a191c 100644 --- a/client/src/app/components/nova/address/AnchorAddressView.tsx +++ b/client/src/app/components/nova/address/AnchorAddressView.tsx @@ -1,17 +1,27 @@ import { AnchorAddress } from "@iota/sdk-wasm-nova/web"; -import React from "react"; +import React, { Reducer, useReducer } from "react"; import { useAnchorAddressState } from "~/helpers/nova/hooks/useAnchorAddressState"; import Spinner from "../../Spinner"; import Bech32Address from "./Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import { IEd25519AddressViewState } from "./Ed25519AddressView"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; interface AnchorAddressViewProps { anchorAddress: AnchorAddress; } +export interface IAnchorAddressViewState { + isAssociatedOutputsLoading: boolean; +} + const AnchorAddressView: React.FC = ({ anchorAddress }) => { const { anchorAddressDetails, isAnchorDetailsLoading } = useAnchorAddressState(anchorAddress); - const isPageLoading = isAnchorDetailsLoading; + const [state, setState] = useReducer>>( + (currentState, newState) => ({ ...currentState, ...newState }), + { isAssociatedOutputsLoading: false }, + ); + const { isAssociatedOutputsLoading } = state; + const isPageLoading = isAnchorDetailsLoading || isAssociatedOutputsLoading; return (
@@ -36,12 +46,11 @@ const AnchorAddressView: React.FC = ({ anchorAddress })
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + /> )} diff --git a/client/src/app/components/nova/address/Ed25519AddressView.tsx b/client/src/app/components/nova/address/Ed25519AddressView.tsx index 17232782d..322c46ff0 100644 --- a/client/src/app/components/nova/address/Ed25519AddressView.tsx +++ b/client/src/app/components/nova/address/Ed25519AddressView.tsx @@ -1,15 +1,27 @@ import { Ed25519Address } from "@iota/sdk-wasm-nova/web"; -import React from "react"; +import React, { Reducer, useReducer } from "react"; import { useEd25519AddressState } from "~/helpers/nova/hooks/useEd25519AddressState"; import Bech32Address from "./Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; +import Spinner from "../../Spinner"; interface Ed25519AddressViewProps { ed25519Address: Ed25519Address; } +export interface IEd25519AddressViewState { + isAssociatedOutputsLoading: boolean; +} + const Ed25519AddressView: React.FC = ({ ed25519Address }) => { const { ed25519AddressDetails } = useEd25519AddressState(ed25519Address); + const [state, setState] = useReducer>>( + (currentState, newState) => ({ ...currentState, ...newState }), + { isAssociatedOutputsLoading: false }, + ); + + const { isAssociatedOutputsLoading } = state; + const isPageLoading = isAssociatedOutputsLoading; return (
@@ -19,6 +31,7 @@ const Ed25519AddressView: React.FC = ({ ed25519Address

{ed25519AddressDetails.label?.replace("Ed25519", "Address")}

+ {isPageLoading && }
@@ -33,12 +46,11 @@ const Ed25519AddressView: React.FC = ({ ed25519Address
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + /> )} diff --git a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx index eee1977ba..2da1d094d 100644 --- a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx +++ b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx @@ -1,15 +1,27 @@ import { ImplicitAccountCreationAddress } from "@iota/sdk-wasm-nova/web"; -import React from "react"; +import React, { Reducer, useReducer } from "react"; import { useImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; import Bech32Address from "./Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import Spinner from "../../Spinner"; +import { IEd25519AddressViewState } from "./Ed25519AddressView"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; interface ImplicitAccountCreationAddressViewProps { implicitAccountCreationAddress: ImplicitAccountCreationAddress; } +export interface IImplicitAccountCreationAddressViewState { + isAssociatedOutputsLoading: boolean; +} + const ImplicitAccountCreationAddressView: React.FC = ({ implicitAccountCreationAddress }) => { const { implicitAccountCreationAddressDetails } = useImplicitAccountCreationAddressState(implicitAccountCreationAddress); + const [state, setState] = useReducer>>( + (currentState, newState) => ({ ...currentState, ...newState }), + { isAssociatedOutputsLoading: false }, + ); + const { isAssociatedOutputsLoading } = state; + const isPageLoading = isAssociatedOutputsLoading; return (
@@ -20,6 +32,7 @@ const ImplicitAccountCreationAddressView: React.FC

{implicitAccountCreationAddressDetails.label?.replace("Ed25519", "Address")}

+ {isPageLoading && }
@@ -33,12 +46,11 @@ const ImplicitAccountCreationAddressView: React.FC
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + /> )} diff --git a/client/src/app/components/nova/address/NftAddressView.tsx b/client/src/app/components/nova/address/NftAddressView.tsx index f898f124f..9df57dba9 100644 --- a/client/src/app/components/nova/address/NftAddressView.tsx +++ b/client/src/app/components/nova/address/NftAddressView.tsx @@ -1,17 +1,27 @@ import { NftAddress } from "@iota/sdk-wasm-nova/web"; -import React from "react"; +import React, { Reducer, useReducer } from "react"; import { useNftAddressState } from "~/helpers/nova/hooks/useNftAddressState"; import Spinner from "../../Spinner"; import Bech32Address from "./Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import { IEd25519AddressViewState } from "./Ed25519AddressView"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; interface NftAddressViewProps { nftAddress: NftAddress; } +export interface INftAddressViewState { + isAssociatedOutputsLoading: boolean; +} + const NftAddressView: React.FC = ({ nftAddress }) => { const { nftAddressDetails, isNftDetailsLoading } = useNftAddressState(nftAddress); - const isPageLoading = isNftDetailsLoading; + const [state, setState] = useReducer>>( + (currentState, newState) => ({ ...currentState, ...newState }), + { isAssociatedOutputsLoading: false }, + ); + const { isAssociatedOutputsLoading } = state; + const isPageLoading = isNftDetailsLoading || isAssociatedOutputsLoading; return (
@@ -36,12 +46,11 @@ const NftAddressView: React.FC = ({ nftAddress }) => {
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + /> )} diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx new file mode 100644 index 000000000..36acdbab3 --- /dev/null +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -0,0 +1,66 @@ +import { AddressType } from "@iota/sdk-wasm-nova/web"; +import React, { useState } from "react"; +import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; +import TabbedSection from "../../../hoc/TabbedSection"; +import AssociatedOutputs from "./association/AssociatedOutputs"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; + +enum DEFAULT_TABS { + AssocOutputs = "Outputs", +} + +const buildDefaultTabsOptions = (associatedOutputCount: number) => ({ + [DEFAULT_TABS.AssocOutputs]: { + disabled: associatedOutputCount === 0, + counter: associatedOutputCount, + infoContent: associatedOuputsMessage, + }, +}); + +interface IAddressPageTabbedSectionsProps { + readonly addressDetails: IAddressDetails; + readonly setAssociatedOutputsLoading: (isLoading: boolean) => void; +} + +export const AddressPageTabbedSections: React.FC = ({ addressDetails, setAssociatedOutputsLoading }) => { + const [outputCount, setOutputCount] = useState(0); + + if (!addressDetails) { + return null; + } + + const defaultSections = [ + , + ]; + + const tabEnums = DEFAULT_TABS; + const defaultTabsOptions = buildDefaultTabsOptions(outputCount); + const tabOptions = defaultTabsOptions; + const tabbedSections = defaultSections; + + switch (addressDetails.type) { + case AddressType.Account: { + break; + } + case AddressType.Nft: { + break; + } + case AddressType.Anchor: { + break; + } + default: { + break; + } + } + + return ( + + {tabbedSections} + + ); +}; From aed231321a78d72d1b68fdba4a7b8ebbd2401782 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Wed, 14 Feb 2024 15:18:54 +0100 Subject: [PATCH 02/31] fix: add components to display native tokens --- .../models/api/nova/nova/IFoundryRequest.ts | 11 ++ .../models/api/nova/nova/IFoundryResponse.ts | 11 ++ api/src/routes.ts | 1 + api/src/routes/nova/foundry/get.ts | 30 +++++ api/src/services/nova/novaApiService.ts | 21 ++++ .../section/AddressPageTabbedSections.tsx | 14 ++- .../address/section/native-tokens/Asset.tsx | 108 ++++++++++++++++++ .../section/native-tokens/AssetProps.tsx | 13 +++ .../section/native-tokens/AssetsTable.scss | 102 +++++++++++++++++ .../section/native-tokens/AssetsTable.tsx | 108 ++++++++++++++++++ .../helpers/nova/hooks/useFoundryDetails.ts | 48 ++++++++ .../api/nova/foundry/IFoundryRequest.ts | 11 ++ .../api/nova/foundry/IFoundryResponse.ts | 9 ++ client/src/services/nova/novaApiClient.ts | 11 ++ 14 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 api/src/models/api/nova/nova/IFoundryRequest.ts create mode 100644 api/src/models/api/nova/nova/IFoundryResponse.ts create mode 100644 api/src/routes/nova/foundry/get.ts create mode 100644 client/src/app/components/nova/address/section/native-tokens/Asset.tsx create mode 100644 client/src/app/components/nova/address/section/native-tokens/AssetProps.tsx create mode 100644 client/src/app/components/nova/address/section/native-tokens/AssetsTable.scss create mode 100644 client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx create mode 100644 client/src/helpers/nova/hooks/useFoundryDetails.ts create mode 100644 client/src/models/api/nova/foundry/IFoundryRequest.ts create mode 100644 client/src/models/api/nova/foundry/IFoundryResponse.ts diff --git a/api/src/models/api/nova/nova/IFoundryRequest.ts b/api/src/models/api/nova/nova/IFoundryRequest.ts new file mode 100644 index 000000000..6c655435c --- /dev/null +++ b/api/src/models/api/nova/nova/IFoundryRequest.ts @@ -0,0 +1,11 @@ +export interface IFoundryRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The foundry id to get the foundry details for. + */ + foundryId: string; +} diff --git a/api/src/models/api/nova/nova/IFoundryResponse.ts b/api/src/models/api/nova/nova/IFoundryResponse.ts new file mode 100644 index 000000000..9bd84790d --- /dev/null +++ b/api/src/models/api/nova/nova/IFoundryResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { OutputResponse } from "@iota/sdk-nova"; +import { IResponse } from "../IResponse"; + +export interface IFoundryResponse extends IResponse { + /** + * The foundry details response. + */ + foundryDetails?: OutputResponse; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index d8b1ade11..b0e0bd2b0 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -207,6 +207,7 @@ export const routes: IRoute[] = [ { path: "/nova/account/:network/:accountId", method: "get", folder: "nova/account", func: "get" }, { path: "/nova/nft/:network/:nftId", method: "get", folder: "nova/nft", func: "get" }, { path: "/nova/anchor/:network/:anchorId", method: "get", folder: "nova/anchor", func: "get" }, + { path: "/nova/foundry/:network/:foundryId", method: "get", folder: "nova/foundry", func: "get" }, { path: "/nova/output/associated/:network/:address", method: "post", diff --git a/api/src/routes/nova/foundry/get.ts b/api/src/routes/nova/foundry/get.ts new file mode 100644 index 000000000..cd901fe82 --- /dev/null +++ b/api/src/routes/nova/foundry/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../factories/serviceFactory"; +import { IFoundryRequest } from "../../../models/api/nova/nova/IFoundryRequest"; +import { IFoundryResponse } from "../../../models/api/nova/nova/IFoundryResponse"; +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"; + +/** + * Get foundry output details by Foundry id. + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: IFoundryRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.foundryId, "foundryId"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.foundryDetails(request.foundryId); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index b3388a8db..b2377c85e 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -10,6 +10,7 @@ import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; import { INftDetailsResponse } from "../../models/api/nova/INftDetailsResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; import { IRewardsResponse } from "../../models/api/nova/IRewardsResponse"; +import { IFoundryResponse } from "../../models/api/nova/nova/IFoundryResponse"; import { INetwork } from "../../models/db/INetwork"; import { HexHelper } from "../../utils/hexHelper"; @@ -144,6 +145,26 @@ export class NovaApiService { } } + /** + * Get the foundry details. + * @param foundryId The foundryId to get the details for. + * @returns The foundry details. + */ + public async foundryDetails(foundryId: string): Promise { + try { + const foundryOutputId = await this.client.foundryOutputId(foundryId); + + if (foundryOutputId) { + const outputResponse = await this.outputDetails(foundryOutputId); + + return outputResponse.error ? { error: outputResponse.error } : { foundryDetails: outputResponse.output }; + } + return { message: "Foundry output not found" }; + } catch { + return { message: "Foundry output not found" }; + } + } + /** * Get the output mana rewards. * @param outputId The outputId to get the rewards for. diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 36acdbab3..e2a954c0b 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -3,18 +3,26 @@ import React, { useState } from "react"; import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; import TabbedSection from "../../../hoc/TabbedSection"; import AssociatedOutputs from "./association/AssociatedOutputs"; +import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +// import AssetsTable from "./native-tokens/AssetsTable"; enum DEFAULT_TABS { + NativeTokens = "Native Tokens", AssocOutputs = "Outputs", } -const buildDefaultTabsOptions = (associatedOutputCount: number) => ({ +const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: number) => ({ [DEFAULT_TABS.AssocOutputs]: { disabled: associatedOutputCount === 0, counter: associatedOutputCount, infoContent: associatedOuputsMessage, }, + [DEFAULT_TABS.NativeTokens]: { + disabled: tokensCount === 0, + counter: tokensCount, + infoContent: nativeTokensMessage, + }, }); interface IAddressPageTabbedSectionsProps { @@ -24,12 +32,14 @@ interface IAddressPageTabbedSectionsProps { export const AddressPageTabbedSections: React.FC = ({ addressDetails, setAssociatedOutputsLoading }) => { const [outputCount, setOutputCount] = useState(0); + const [tokensCount] = useState(0); if (!addressDetails) { return null; } const defaultSections = [ + // , = ({ tableFormat, token }) => { + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const [foundryDetails, isLoading] = useFoundryDetails(network, token.id); + const [tokenMetadata, setTokenMetadata] = useState(null); + const [isWhitelisted] = useTokenRegistryNativeTokenCheck(token.id); + + useEffect(() => { + if (isWhitelisted && foundryDetails) { + const immutableFeatures = (foundryDetails?.output as unknown as FoundryOutput).immutableFeatures; + + const metadata = immutableFeatures?.find((feature) => feature.type === FeatureType.Metadata) as MetadataFeature; + + if (metadata) { + updateTokenInfo(metadata); + } + } + }, [isWhitelisted, foundryDetails]); + + const updateTokenInfo = (metadata: MetadataFeature): void => { + const validator = new JsonSchemaValidator(); + + try { + const tokenInfo = JSON.parse(Converter.hexToUtf8(metadata.data)) as Irc30Metadata; + const result = validator.validate(tokenInfo, tokenSchemeIRC30); + + if (result.valid) { + setTokenMetadata(tokenInfo); + } + } catch {} + }; + + const buildTokenName = (name: string, logoUrl?: string): string | ReactElement => { + if (logoUrl) { + return ( + + {name} + {name} + + ); + } + return name; + }; + + /** + * Render the component. + * @returns The node to render. + */ + return tableFormat ? ( + + + {isLoading ? : tokenMetadata?.name ? buildTokenName(tokenMetadata.name, tokenMetadata.logoUrl) : "-"} + + {isLoading ? : tokenMetadata?.symbol ?? "-"} + + + + {token.amount.toString() ?? "-"} + + ) : ( +
+
+
Name
+
+ {isLoading ? ( + + ) : tokenMetadata?.name ? ( + buildTokenName(tokenMetadata.name, tokenMetadata.logoUrl) + ) : ( + "-" + )} +
+
+
+
Symbol
+
{isLoading ? : tokenMetadata?.symbol ?? "-"}
+
+
+
Token id
+
+ +
+
+
+
Quantity
+
{token.amount.toString() ?? "-"}
+
+
+ ); +}; + +export default Asset; diff --git a/client/src/app/components/nova/address/section/native-tokens/AssetProps.tsx b/client/src/app/components/nova/address/section/native-tokens/AssetProps.tsx new file mode 100644 index 000000000..5d1dc8511 --- /dev/null +++ b/client/src/app/components/nova/address/section/native-tokens/AssetProps.tsx @@ -0,0 +1,13 @@ +import { IToken } from "~models/api/stardust/foundry/IToken"; + +export interface AssetProps { + /** + * Token + */ + token: IToken; + + /** + * True if the asset is rendered like a table + */ + tableFormat?: boolean; +} diff --git a/client/src/app/components/nova/address/section/native-tokens/AssetsTable.scss b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.scss new file mode 100644 index 000000000..f203b9871 --- /dev/null +++ b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.scss @@ -0,0 +1,102 @@ +@import "../../../../../../scss/fonts"; +@import "../../../../../../scss/media-queries"; +@import "../../../../../../scss/mixins"; +@import "../../../../../../scss/variables"; +@import "../../../../../../scss/themes"; + +.asset-table { + width: 100%; + border-spacing: 12px 28px; + border-collapse: separate; + + @include tablet-down { + display: none; + } + + tr { + @include font-size(14px); + + color: $gray-7; + font-family: $inter; + letter-spacing: 0.5px; + + th { + @include font-size(12px); + + color: $gray-6; + font-weight: 600; + text-align: left; + text-transform: uppercase; + } + + td { + color: var(--body-color); + + &.highlight { + color: var(--link-color); + @include font-size(14px); + + font-family: $ibm-plex-mono; + font-weight: normal; + letter-spacing: 0.02em; + line-height: 20px; + + a { + max-width: 200px; + } + } + &.truncate { + max-width: 150px; + } + } + } +} + +.asset-cards { + display: none; + + @include tablet-down { + display: block; + + .asset-card { + margin-bottom: 48px; + + .field { + margin-bottom: 8px; + + .label { + color: $gray-6; + font-family: $inter; + letter-spacing: 0.5px; + + @include font-size(14px, 21px); + } + + .value { + @include font-size(14px, 21px); + + color: var(--body-color); + font-family: $metropolis; + font-weight: 700; + + .highlight { + color: var(--link-color); + @include font-size(14px); + + font-family: $ibm-plex-mono; + font-weight: normal; + letter-spacing: 0.02em; + line-height: 20px; + max-width: 200px; + } + } + } + } + } +} + +.token__logo { + vertical-align: middle; + height: 16px; + width: 16px; +} diff --git a/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx new file mode 100644 index 000000000..99e13dc7a --- /dev/null +++ b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx @@ -0,0 +1,108 @@ +import { OutputType, OutputResponse, CommonOutput, INativeToken, FeatureType, NativeTokenFeature } from "@iota/sdk-wasm-nova/web"; +import React, { useEffect, useState } from "react"; +import Asset from "./Asset"; +import Pagination from "../../../../Pagination"; +import { plainToInstance } from "class-transformer"; +import "./AssetsTable.scss"; + +interface AssetsTableProps { + readonly outputs: OutputResponse[] | null; + readonly setTokensCount?: (count: number) => void; +} + +const TOKEN_PAGE_SIZE: number = 10; + +const AssetsTable: React.FC = ({ outputs, setTokensCount }) => { + const [tokens, setTokens] = useState(); + const [currentPage, setCurrentPage] = useState([]); + const [pageNumber, setPageNumber] = useState(1); + + useEffect(() => { + if (setTokensCount) { + setTokensCount(0); + } + + if (outputs) { + const theTokens: INativeToken[] = []; + for (const outputResponse of outputs) { + const output = outputResponse.output as CommonOutput; + if (output.type === OutputType.Basic || output.type === OutputType.Foundry) { + let nativeTokenFeature = output.features?.find((feature) => feature.type === FeatureType.NativeToken); + nativeTokenFeature = plainToInstance( + NativeTokenFeature, + nativeTokenFeature as NativeTokenFeature, + ) as unknown as NativeTokenFeature; + + if (nativeTokenFeature) { + const token = nativeTokenFeature as NativeTokenFeature; + const existingToken = theTokens.find((t) => t.id === token.asNativeToken().id); + // Convert to BigInt again in case the amount is hex + const amount = BigInt(token.amount); + if (existingToken) { + existingToken.amount += amount; + } else { + theTokens.push({ id: token.id, amount }); + } + } + } + } + + setTokens(theTokens); + if (setTokensCount) { + setTokensCount(theTokens.length); + } + } + }, [outputs]); + + useEffect(() => { + const from = (pageNumber - 1) * TOKEN_PAGE_SIZE; + const to = from + TOKEN_PAGE_SIZE; + if (tokens) { + setCurrentPage(tokens.slice(from, to)); + } + }, [tokens, pageNumber]); + + return tokens && tokens?.length > 0 ? ( +
+ + + + + + + + + + + {currentPage.map((token, k) => ( + + + + ))} + +
NameSymbolToken idQuantity
+ + {/* Only visible in mobile -- Card assets*/} +
+ {currentPage.map((token, k) => ( + + + + ))} +
+ setPageNumber(number)} + /> +
+ ) : null; +}; + +AssetsTable.defaultProps = { + setTokenCount: undefined, +}; + +export default AssetsTable; diff --git a/client/src/helpers/nova/hooks/useFoundryDetails.ts b/client/src/helpers/nova/hooks/useFoundryDetails.ts new file mode 100644 index 000000000..0e17bda35 --- /dev/null +++ b/client/src/helpers/nova/hooks/useFoundryDetails.ts @@ -0,0 +1,48 @@ +import { OutputResponse } 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 { HexHelper } from "~/helpers/stardust/hexHelper"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +/** + * Fetch foundry output details + * @param network The Network in context + * @param foundryId The foundry id + * @returns The output response, loading bool and an error message. + */ +export function useFoundryDetails(network: string, foundryId: string | null): [OutputResponse | null, boolean, string?] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [foundryDetails, setFoundryDetails] = useState(null); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (foundryId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .foundryDetails({ + network, + foundryId: HexHelper.addPrefix(foundryId), + }) + .then((response) => { + if (isMounted) { + setFoundryDetails(response.foundryDetails ?? null); + setError(response.error); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, foundryId]); + + return [foundryDetails, isLoading, error]; +} diff --git a/client/src/models/api/nova/foundry/IFoundryRequest.ts b/client/src/models/api/nova/foundry/IFoundryRequest.ts new file mode 100644 index 000000000..6c655435c --- /dev/null +++ b/client/src/models/api/nova/foundry/IFoundryRequest.ts @@ -0,0 +1,11 @@ +export interface IFoundryRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The foundry id to get the foundry details for. + */ + foundryId: string; +} diff --git a/client/src/models/api/nova/foundry/IFoundryResponse.ts b/client/src/models/api/nova/foundry/IFoundryResponse.ts new file mode 100644 index 000000000..6fed01eca --- /dev/null +++ b/client/src/models/api/nova/foundry/IFoundryResponse.ts @@ -0,0 +1,9 @@ +import { OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "../IResponse"; + +export interface IFoundryResponse extends IResponse { + /** + * The foundry details response. + */ + foundryDetails?: OutputResponse; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 6635f8319..631e0a008 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -19,6 +19,8 @@ import { INftDetailsRequest } from "~/models/api/nova/INftDetailsRequest"; import { INftDetailsResponse } from "~/models/api/nova/INftDetailsResponse"; import { IAnchorDetailsRequest } from "~/models/api/nova/IAnchorDetailsRequest"; import { IAnchorDetailsResponse } from "~/models/api/nova/IAnchorDetailsResponse"; +import { IFoundryRequest } from "~/models/api/nova/foundry/IFoundryRequest"; +import { IFoundryResponse } from "~/models/api/nova/foundry/IFoundryResponse"; /** * Class to handle api communications on nova. @@ -87,6 +89,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/anchor/${request.network}/${request.anchorId}`, "get"); } + /** + * Get the foundry output details. + * @param request The request to send. + * @returns The response from the request. + */ + public async foundryDetails(request: IFoundryRequest): Promise { + return this.callApi(`nova/foundry/${request.network}/${request.foundryId}`, "get"); + } + /** * Get the associated outputs. * @param request The request to send. From 8ffe165656e6587def132da8b8c2711d01ca101a Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Wed, 14 Feb 2024 15:54:48 +0100 Subject: [PATCH 03/31] Remove unused code in AddressPageTabbedSections component --- .../section/AddressPageTabbedSections.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 36acdbab3..82ba54e4a 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -1,4 +1,3 @@ -import { AddressType } from "@iota/sdk-wasm-nova/web"; import React, { useState } from "react"; import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; import TabbedSection from "../../../hoc/TabbedSection"; @@ -43,21 +42,6 @@ export const AddressPageTabbedSections: React.FC {tabbedSections} From 44fd78a9bc1e0949a7b991f8a8d072484f4971b6 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Wed, 14 Feb 2024 16:21:39 +0100 Subject: [PATCH 04/31] remove unused interfaces --- client/src/app/components/nova/address/AccountAddressView.tsx | 4 ---- client/src/app/components/nova/address/AnchorAddressView.tsx | 4 ---- client/src/app/components/nova/address/Ed25519AddressView.tsx | 4 ---- .../nova/address/ImplicitAccountCreationAddressView.tsx | 4 ---- client/src/app/components/nova/address/NftAddressView.tsx | 4 ---- 5 files changed, 20 deletions(-) diff --git a/client/src/app/components/nova/address/AccountAddressView.tsx b/client/src/app/components/nova/address/AccountAddressView.tsx index 42348213b..a818ab1ba 100644 --- a/client/src/app/components/nova/address/AccountAddressView.tsx +++ b/client/src/app/components/nova/address/AccountAddressView.tsx @@ -10,10 +10,6 @@ interface AccountAddressViewProps { accountAddress: AccountAddress; } -export interface IAccountAddressViewState { - isAssociatedOutputsLoading: boolean; -} - const AccountAddressView: React.FC = ({ accountAddress }) => { const [state, setState] = useAccountAddressState(accountAddress); const { accountAddressDetails, totalBalance, availableBalance, isAccountDetailsLoading, isAssociatedOutputsLoading } = state; diff --git a/client/src/app/components/nova/address/AnchorAddressView.tsx b/client/src/app/components/nova/address/AnchorAddressView.tsx index d9260af53..26133497d 100644 --- a/client/src/app/components/nova/address/AnchorAddressView.tsx +++ b/client/src/app/components/nova/address/AnchorAddressView.tsx @@ -10,10 +10,6 @@ interface AnchorAddressViewProps { anchorAddress: AnchorAddress; } -export interface IAnchorAddressViewState { - isAssociatedOutputsLoading: boolean; -} - const AnchorAddressView: React.FC = ({ anchorAddress }) => { const [state, setState] = useAnchorAddressState(anchorAddress); const { anchorAddressDetails, totalBalance, availableBalance, isAnchorDetailsLoading, isAssociatedOutputsLoading } = state; diff --git a/client/src/app/components/nova/address/Ed25519AddressView.tsx b/client/src/app/components/nova/address/Ed25519AddressView.tsx index 4a17fbca4..937111802 100644 --- a/client/src/app/components/nova/address/Ed25519AddressView.tsx +++ b/client/src/app/components/nova/address/Ed25519AddressView.tsx @@ -10,10 +10,6 @@ interface Ed25519AddressViewProps { ed25519Address: Ed25519Address; } -export interface IEd25519AddressViewState { - isAssociatedOutputsLoading: boolean; -} - const Ed25519AddressView: React.FC = ({ ed25519Address }) => { const [state, setState] = useEd25519AddressState(ed25519Address); const { ed25519AddressDetails, totalBalance, availableBalance, isAssociatedOutputsLoading } = state; diff --git a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx index c874af2cc..03e25303c 100644 --- a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx +++ b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx @@ -10,10 +10,6 @@ interface ImplicitAccountCreationAddressViewProps { implicitAccountCreationAddress: ImplicitAccountCreationAddress; } -export interface IImplicitAccountCreationAddressViewState { - isAssociatedOutputsLoading: boolean; -} - const ImplicitAccountCreationAddressView: React.FC = ({ implicitAccountCreationAddress }) => { const [state, setState] = useImplicitAccountCreationAddressState(implicitAccountCreationAddress); const { implicitAccountCreationAddressDetails, totalBalance, availableBalance, isAssociatedOutputsLoading } = state; diff --git a/client/src/app/components/nova/address/NftAddressView.tsx b/client/src/app/components/nova/address/NftAddressView.tsx index 143351284..a13f2d3c1 100644 --- a/client/src/app/components/nova/address/NftAddressView.tsx +++ b/client/src/app/components/nova/address/NftAddressView.tsx @@ -10,10 +10,6 @@ interface NftAddressViewProps { nftAddress: NftAddress; } -export interface INftAddressViewState { - isAssociatedOutputsLoading: boolean; -} - const NftAddressView: React.FC = ({ nftAddress }) => { const [state, setState] = useNftAddressState(nftAddress); const { nftAddressDetails, totalBalance, availableBalance, isNftDetailsLoading, isAssociatedOutputsLoading } = state; From 35d1cbb0d34f65bc954098badcf13443247cdc29 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Thu, 15 Feb 2024 09:45:01 +0100 Subject: [PATCH 05/31] Update address states and Add basic outputs API endpoint --- .../models/api/nova/IAddressDetailsRequest.ts | 11 ++++ .../api/nova/IAddressDetailsResponse.ts | 11 ++++ .../nova/{nova => foundry}/IFoundryRequest.ts | 0 .../{nova => foundry}/IFoundryResponse.ts | 0 api/src/routes.ts | 6 ++ .../routes/nova/address/outputs/basic/get.ts | 29 +++++++++ api/src/routes/nova/foundry/get.ts | 4 +- api/src/services/nova/novaApiService.ts | 61 ++++++++++++++++++- .../nova/address/AccountAddressView.tsx | 12 ++-- .../nova/address/AnchorAddressView.tsx | 12 ++-- .../nova/address/Ed25519AddressView.tsx | 14 ++--- .../ImplicitAccountCreationAddressView.tsx | 12 ++-- .../nova/address/NftAddressView.tsx | 12 ++-- .../section/AddressPageTabbedSections.tsx | 26 +++++--- .../nova/hooks/useAccountAddressState.ts | 20 ++++-- .../nova/hooks/useAddressBasicOutputs.ts | 43 +++++++++++++ .../nova/hooks/useAnchorAddressState.ts | 20 ++++-- .../nova/hooks/useEd25519AddressState.ts | 20 ++++-- .../useImplicitAccountCreationAddressState.ts | 20 ++++-- .../helpers/nova/hooks/useNftAddressState.ts | 20 ++++-- .../nova/address/IAddressDetailsRequest.ts | 11 ++++ .../nova/address/IAddressDetailsResponse.ts | 9 +++ client/src/services/nova/novaApiClient.ts | 11 ++++ 23 files changed, 311 insertions(+), 73 deletions(-) create mode 100644 api/src/models/api/nova/IAddressDetailsRequest.ts create mode 100644 api/src/models/api/nova/IAddressDetailsResponse.ts rename api/src/models/api/nova/{nova => foundry}/IFoundryRequest.ts (100%) rename api/src/models/api/nova/{nova => foundry}/IFoundryResponse.ts (100%) create mode 100644 api/src/routes/nova/address/outputs/basic/get.ts create mode 100644 client/src/helpers/nova/hooks/useAddressBasicOutputs.ts create mode 100644 client/src/models/api/nova/address/IAddressDetailsRequest.ts create mode 100644 client/src/models/api/nova/address/IAddressDetailsResponse.ts diff --git a/api/src/models/api/nova/IAddressDetailsRequest.ts b/api/src/models/api/nova/IAddressDetailsRequest.ts new file mode 100644 index 000000000..bddf1e371 --- /dev/null +++ b/api/src/models/api/nova/IAddressDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface IAddressDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 address to get the basic output ids for. + */ + address: string; +} diff --git a/api/src/models/api/nova/IAddressDetailsResponse.ts b/api/src/models/api/nova/IAddressDetailsResponse.ts new file mode 100644 index 000000000..56329c303 --- /dev/null +++ b/api/src/models/api/nova/IAddressDetailsResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { OutputResponse } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface IAddressDetailsResponse extends IResponse { + /** + * The outputs data. + */ + outputs?: OutputResponse[]; +} diff --git a/api/src/models/api/nova/nova/IFoundryRequest.ts b/api/src/models/api/nova/foundry/IFoundryRequest.ts similarity index 100% rename from api/src/models/api/nova/nova/IFoundryRequest.ts rename to api/src/models/api/nova/foundry/IFoundryRequest.ts diff --git a/api/src/models/api/nova/nova/IFoundryResponse.ts b/api/src/models/api/nova/foundry/IFoundryResponse.ts similarity index 100% rename from api/src/models/api/nova/nova/IFoundryResponse.ts rename to api/src/models/api/nova/foundry/IFoundryResponse.ts diff --git a/api/src/routes.ts b/api/src/routes.ts index 29197b7f8..18a36cf33 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -215,6 +215,12 @@ export const routes: IRoute[] = [ { path: "/nova/nft/:network/:nftId", method: "get", folder: "nova/nft", func: "get" }, { path: "/nova/anchor/:network/:anchorId", method: "get", folder: "nova/anchor", func: "get" }, { path: "/nova/foundry/:network/:foundryId", method: "get", folder: "nova/foundry", func: "get" }, + { + path: "/nova/address/outputs/basic/:network/:address", + method: "get", + folder: "nova/address/outputs/basic", + func: "get", + }, { path: "/nova/output/associated/:network/:address", method: "post", diff --git a/api/src/routes/nova/address/outputs/basic/get.ts b/api/src/routes/nova/address/outputs/basic/get.ts new file mode 100644 index 000000000..5daa98c6c --- /dev/null +++ b/api/src/routes/nova/address/outputs/basic/get.ts @@ -0,0 +1,29 @@ +import { ServiceFactory } from "../../../../../factories/serviceFactory"; +import { IAddressDetailsRequest } from "../../../../../models/api/nova/IAddressDetailsRequest"; +import { IAddressDetailsResponse } from "../../../../../models/api/nova/IAddressDetailsResponse"; +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 basic output details by address. + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: IAddressDetailsRequest): 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 {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.basicOutputDetailsByAddress(request.address); +} diff --git a/api/src/routes/nova/foundry/get.ts b/api/src/routes/nova/foundry/get.ts index cd901fe82..e27384a49 100644 --- a/api/src/routes/nova/foundry/get.ts +++ b/api/src/routes/nova/foundry/get.ts @@ -1,6 +1,6 @@ import { ServiceFactory } from "../../../factories/serviceFactory"; -import { IFoundryRequest } from "../../../models/api/nova/nova/IFoundryRequest"; -import { IFoundryResponse } from "../../../models/api/nova/nova/IFoundryResponse"; +import { IFoundryRequest } from "../../../models/api/nova/foundry/IFoundryRequest"; +import { IFoundryResponse } from "../../../models/api/nova/foundry/IFoundryResponse"; import { IConfiguration } from "../../../models/configuration/IConfiguration"; import { NOVA } from "../../../models/db/protocolVersion"; import { NetworkService } from "../../../services/networkService"; diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 15eb25f19..e27b5773f 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -1,9 +1,11 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import { Client } from "@iota/sdk-nova"; +import { Client, OutputResponse } from "@iota/sdk-nova"; import { ServiceFactory } from "../../factories/serviceFactory"; import logger from "../../logger"; +import { IFoundryResponse } from "../../models/api/nova/foundry/IFoundryResponse"; import { IAccountDetailsResponse } from "../../models/api/nova/IAccountDetailsResponse"; +import { IAddressDetailsResponse } from "../../models/api/nova/IAddressDetailsResponse"; import { IAnchorDetailsResponse } from "../../models/api/nova/IAnchorDetailsResponse"; import { IBlockDetailsResponse } from "../../models/api/nova/IBlockDetailsResponse"; import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; @@ -11,7 +13,6 @@ 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 { IFoundryResponse } from "../../models/api/nova/nova/IFoundryResponse"; import { INetwork } from "../../models/db/INetwork"; import { HexHelper } from "../../utils/hexHelper"; import { SearchExecutor } from "../../utils/nova/searchExecutor"; @@ -168,12 +169,66 @@ export class NovaApiService { return outputResponse.error ? { error: outputResponse.error } : { foundryDetails: outputResponse.output }; } - return { message: "Foundry output not found" }; } catch { return { message: "Foundry output not found" }; } } + /** + * Get the outputs details. + * @param outputIds The output ids to get the details. + * @returns The item details. + */ + public async outputsDetails(outputIds: string[]): Promise { + const promises: Promise[] = []; + const outputResponses: OutputResponse[] = []; + + for (const outputId of outputIds) { + const promise = this.outputDetails(outputId); + promises.push(promise); + } + try { + await Promise.all(promises).then((results) => { + for (const outputDetails of results) { + if (outputDetails.output?.output && outputDetails.output?.metadata) { + outputResponses.push(outputDetails.output); + } + } + }); + + return outputResponses; + } catch (e) { + logger.error(`Fetching outputs details failed. Cause: ${e}`); + } + } + + /** + * Get the relevant basic output details for an address. + * @param addressBech32 The address in bech32 format. + * @returns The basic output details. + */ + public async basicOutputDetailsByAddress(addressBech32: string): Promise { + let cursor: string | undefined; + let outputIds: string[] = []; + + do { + try { + const outputIdsResponse = await this.client.basicOutputIds({ address: addressBech32, cursor: cursor ?? "" }); + + outputIds = outputIds.concat(outputIdsResponse.items); + cursor = outputIdsResponse.cursor; + } catch (e) { + logger.error(`Fetching basic output ids failed. Cause: ${e}`); + } + } while (cursor); + + const outputResponses = await this.outputsDetails(outputIds); + + return { + outputs: outputResponses, + }; + } + /** * Get the output mana rewards. * @param outputId The outputId to get the rewards for. diff --git a/client/src/app/components/nova/address/AccountAddressView.tsx b/client/src/app/components/nova/address/AccountAddressView.tsx index a818ab1ba..248b7c109 100644 --- a/client/src/app/components/nova/address/AccountAddressView.tsx +++ b/client/src/app/components/nova/address/AccountAddressView.tsx @@ -12,17 +12,17 @@ interface AccountAddressViewProps { const AccountAddressView: React.FC = ({ accountAddress }) => { const [state, setState] = useAccountAddressState(accountAddress); - const { accountAddressDetails, totalBalance, availableBalance, isAccountDetailsLoading, isAssociatedOutputsLoading } = state; + const { addressDetails, totalBalance, availableBalance, isAccountDetailsLoading, isAssociatedOutputsLoading } = state; const isPageLoading = isAccountDetailsLoading || isAssociatedOutputsLoading; return (
- {accountAddressDetails && ( + {addressDetails && (
-

{accountAddressDetails.label?.replace("Ed25519", "Address")}

+

{addressDetails.label?.replace("Ed25519", "Address")}

{isPageLoading && }
@@ -34,7 +34,7 @@ const AccountAddressView: React.FC = ({ accountAddress
- + {totalBalance !== null && ( = ({ accountAddress
setState({ isAssociatedOutputsLoading: val })} />
diff --git a/client/src/app/components/nova/address/AnchorAddressView.tsx b/client/src/app/components/nova/address/AnchorAddressView.tsx index 26133497d..3c30352f7 100644 --- a/client/src/app/components/nova/address/AnchorAddressView.tsx +++ b/client/src/app/components/nova/address/AnchorAddressView.tsx @@ -12,17 +12,17 @@ interface AnchorAddressViewProps { const AnchorAddressView: React.FC = ({ anchorAddress }) => { const [state, setState] = useAnchorAddressState(anchorAddress); - const { anchorAddressDetails, totalBalance, availableBalance, isAnchorDetailsLoading, isAssociatedOutputsLoading } = state; + const { addressDetails, totalBalance, availableBalance, isAnchorDetailsLoading, isAssociatedOutputsLoading } = state; const isPageLoading = isAnchorDetailsLoading || isAssociatedOutputsLoading; return (
- {anchorAddressDetails && ( + {addressDetails && (
-

{anchorAddressDetails.label?.replace("Ed25519", "Address")}

+

{addressDetails.label?.replace("Ed25519", "Address")}

{isPageLoading && }
@@ -34,7 +34,7 @@ const AnchorAddressView: React.FC = ({ anchorAddress })
- + {totalBalance !== null && ( = ({ anchorAddress })
setState({ isAssociatedOutputsLoading: val })} />
diff --git a/client/src/app/components/nova/address/Ed25519AddressView.tsx b/client/src/app/components/nova/address/Ed25519AddressView.tsx index 937111802..6c89a5717 100644 --- a/client/src/app/components/nova/address/Ed25519AddressView.tsx +++ b/client/src/app/components/nova/address/Ed25519AddressView.tsx @@ -12,17 +12,17 @@ interface Ed25519AddressViewProps { const Ed25519AddressView: React.FC = ({ ed25519Address }) => { const [state, setState] = useEd25519AddressState(ed25519Address); - const { ed25519AddressDetails, totalBalance, availableBalance, isAssociatedOutputsLoading } = state; - const isPageLoading = isAssociatedOutputsLoading; + const { addressDetails, totalBalance, availableBalance, isAssociatedOutputsLoading, isBasicOutputsLoading } = state; + const isPageLoading = isAssociatedOutputsLoading || isBasicOutputsLoading; return (
- {ed25519AddressDetails && ( + {addressDetails && (
-

{ed25519AddressDetails.label?.replace("Ed25519", "Address")}

+

{addressDetails.label?.replace("Ed25519", "Address")}

{isPageLoading && }
@@ -34,7 +34,7 @@ const Ed25519AddressView: React.FC = ({ ed25519Address
- + {totalBalance !== null && ( = ({ ed25519Address
setState({ isAssociatedOutputsLoading: val })} />
diff --git a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx index 03e25303c..05e0256ab 100644 --- a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx +++ b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx @@ -12,17 +12,17 @@ interface ImplicitAccountCreationAddressViewProps { const ImplicitAccountCreationAddressView: React.FC = ({ implicitAccountCreationAddress }) => { const [state, setState] = useImplicitAccountCreationAddressState(implicitAccountCreationAddress); - const { implicitAccountCreationAddressDetails, totalBalance, availableBalance, isAssociatedOutputsLoading } = state; + const { addressDetails, totalBalance, availableBalance, isAssociatedOutputsLoading } = state; const isPageLoading = isAssociatedOutputsLoading; return (
- {implicitAccountCreationAddressDetails && ( + {addressDetails && (
-

{implicitAccountCreationAddressDetails.label?.replace("Ed25519", "Address")}

+

{addressDetails.label?.replace("Ed25519", "Address")}

{isPageLoading && }
@@ -34,7 +34,7 @@ const ImplicitAccountCreationAddressView: React.FC
- + {totalBalance !== null && (
setState({ isAssociatedOutputsLoading: val })} />
diff --git a/client/src/app/components/nova/address/NftAddressView.tsx b/client/src/app/components/nova/address/NftAddressView.tsx index a13f2d3c1..51431739e 100644 --- a/client/src/app/components/nova/address/NftAddressView.tsx +++ b/client/src/app/components/nova/address/NftAddressView.tsx @@ -12,17 +12,17 @@ interface NftAddressViewProps { const NftAddressView: React.FC = ({ nftAddress }) => { const [state, setState] = useNftAddressState(nftAddress); - const { nftAddressDetails, totalBalance, availableBalance, isNftDetailsLoading, isAssociatedOutputsLoading } = state; + const { addressDetails, totalBalance, availableBalance, isNftDetailsLoading, isAssociatedOutputsLoading } = state; const isPageLoading = isNftDetailsLoading || isAssociatedOutputsLoading; return (
- {nftAddressDetails && ( + {addressDetails && (
-

{nftAddressDetails.label?.replace("Ed25519", "Address")}

+

{addressDetails.label?.replace("Ed25519", "Address")}

{isPageLoading && }
@@ -34,7 +34,7 @@ const NftAddressView: React.FC = ({ nftAddress }) => {
- + {totalBalance !== null && ( = ({ nftAddress }) => {
setState({ isAssociatedOutputsLoading: val })} />
diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 90c06f768..61e796402 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -3,8 +3,12 @@ import associatedOuputsMessage from "~assets/modals/stardust/address/associated- import TabbedSection from "../../../hoc/TabbedSection"; import AssociatedOutputs from "./association/AssociatedOutputs"; import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; -import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; -// import AssetsTable from "./native-tokens/AssetsTable"; +import { IAccountAddressState } from "~/helpers/nova/hooks/useAccountAddressState"; +import { INftAddressState } from "~/helpers/nova/hooks/useNftAddressState"; +import { IAnchorAddressState } from "~/helpers/nova/hooks/useAnchorAddressState"; +import { IEd25519AddressState } from "~/helpers/nova/hooks/useEd25519AddressState"; +import AssetsTable from "./native-tokens/AssetsTable"; +import { IImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; enum DEFAULT_TABS { NativeTokens = "Native Tokens", @@ -14,31 +18,39 @@ enum DEFAULT_TABS { const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: number) => ({ [DEFAULT_TABS.AssocOutputs]: { disabled: associatedOutputCount === 0, + hidden: associatedOutputCount === 0, counter: associatedOutputCount, infoContent: associatedOuputsMessage, }, [DEFAULT_TABS.NativeTokens]: { disabled: tokensCount === 0, + hidden: tokensCount === 0, counter: tokensCount, infoContent: nativeTokensMessage, }, }); interface IAddressPageTabbedSectionsProps { - readonly addressDetails: IAddressDetails; + readonly addressState: + | IEd25519AddressState + | IAccountAddressState + | INftAddressState + | IAnchorAddressState + | IImplicitAccountCreationAddressState; readonly setAssociatedOutputsLoading: (isLoading: boolean) => void; } -export const AddressPageTabbedSections: React.FC = ({ addressDetails, setAssociatedOutputsLoading }) => { +export const AddressPageTabbedSections: React.FC = ({ addressState, setAssociatedOutputsLoading }) => { const [outputCount, setOutputCount] = useState(0); - const [tokensCount] = useState(0); + const [tokensCount, setTokensCount] = useState(0); - if (!addressDetails) { + if (!addressState.addressDetails) { return null; } + const { addressDetails, addressBasicOutputs } = addressState; const defaultSections = [ - // , + , { const locationState = location.state as IAddressPageLocationProps; @@ -53,7 +59,7 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres setState({ ...initialState, - accountAddressDetails: addressDetails, + addressDetails, }); }, []); @@ -63,8 +69,10 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres isAccountDetailsLoading, totalBalance, availableBalance, + addressBasicOutputs, + isBasicOutputsLoading, }); - }, [accountOutput, totalBalance, availableBalance, isAccountDetailsLoading]); + }, [accountOutput, totalBalance, availableBalance, addressBasicOutputs, isAccountDetailsLoading, isBasicOutputsLoading]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useAddressBasicOutputs.ts b/client/src/helpers/nova/hooks/useAddressBasicOutputs.ts new file mode 100644 index 000000000..a9e8fc920 --- /dev/null +++ b/client/src/helpers/nova/hooks/useAddressBasicOutputs.ts @@ -0,0 +1,43 @@ +import { OutputResponse } 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"; + +/** + * Fetch Address basic UTXOs + * @param network The Network in context + * @param addressBech32 The address in bech32 format + * @returns The output responses and loading bool. + */ +export function useAddressBasicOutputs(network: string, addressBech32: string | null): [OutputResponse[] | null, boolean] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [outputs, setOutputs] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + setOutputs(null); + if (addressBech32) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .basicOutputsDetails({ network, address: addressBech32 }) + .then((response) => { + if (!response?.error && response.outputs && isMounted) { + setOutputs(response.outputs); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, addressBech32]); + + return [outputs, isLoading]; +} diff --git a/client/src/helpers/nova/hooks/useAnchorAddressState.ts b/client/src/helpers/nova/hooks/useAnchorAddressState.ts index b11062bce..a688fd186 100644 --- a/client/src/helpers/nova/hooks/useAnchorAddressState.ts +++ b/client/src/helpers/nova/hooks/useAnchorAddressState.ts @@ -1,5 +1,5 @@ import { Reducer, useEffect, useReducer } from "react"; -import { AnchorAddress, AnchorOutput } from "@iota/sdk-wasm-nova/web"; +import { AnchorAddress, AnchorOutput, OutputResponse } from "@iota/sdk-wasm-nova/web"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useAnchorDetails } from "./useAnchorDetails"; import { useLocation, useParams } from "react-router-dom"; @@ -7,21 +7,26 @@ import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; +import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; export interface IAnchorAddressState { - anchorAddressDetails: IAddressDetails | null; + addressDetails: IAddressDetails | null; anchorOutput: AnchorOutput | null; availableBalance: number | null; totalBalance: number | null; + addressBasicOutputs: OutputResponse[] | null; + isBasicOutputsLoading: boolean; isAnchorDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; } const initialState = { - anchorAddressDetails: null, + addressDetails: null, anchorOutput: null, totalBalance: null, availableBalance: null, + addressBasicOutputs: null, + isBasicOutputsLoading: false, isAnchorDetailsLoading: true, isAssociatedOutputsLoading: false, }; @@ -43,7 +48,8 @@ export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressSt ); const { anchorOutput, isLoading: isAnchorDetailsLoading } = useAnchorDetails(network, address.anchorId); - const { totalBalance, availableBalance } = useAddressBalance(network, state.anchorAddressDetails, anchorOutput); + const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, anchorOutput); + const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -53,7 +59,7 @@ export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressSt setState({ ...initialState, - anchorAddressDetails: addressDetails, + addressDetails, }); }, []); @@ -62,9 +68,11 @@ export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressSt anchorOutput, totalBalance, availableBalance, + addressBasicOutputs, + isBasicOutputsLoading, isAnchorDetailsLoading, }); - }, [anchorOutput, totalBalance, availableBalance, isAnchorDetailsLoading]); + }, [anchorOutput, totalBalance, availableBalance, addressBasicOutputs, isBasicOutputsLoading, isAnchorDetailsLoading]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useEd25519AddressState.ts b/client/src/helpers/nova/hooks/useEd25519AddressState.ts index d5cbffe8b..cc2866a50 100644 --- a/client/src/helpers/nova/hooks/useEd25519AddressState.ts +++ b/client/src/helpers/nova/hooks/useEd25519AddressState.ts @@ -1,22 +1,27 @@ -import { Ed25519Address } from "@iota/sdk-wasm-nova/web"; +import { Ed25519Address, OutputResponse } from "@iota/sdk-wasm-nova/web"; import { Reducer, useEffect, useReducer } from "react"; import { useLocation } from "react-router-dom"; import { useNetworkInfoNova } from "../networkInfo"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; +import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; export interface IEd25519AddressState { - ed25519AddressDetails: IAddressDetails | null; + addressDetails: IAddressDetails | null; totalBalance: number | null; availableBalance: number | null; + addressBasicOutputs: OutputResponse[] | null; + isBasicOutputsLoading: boolean; isAssociatedOutputsLoading: boolean; } const initialState = { - ed25519AddressDetails: null, + addressDetails: null, totalBalance: null, availableBalance: null, + addressBasicOutputs: null, + isBasicOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -35,7 +40,8 @@ export const useEd25519AddressState = (address: Ed25519Address): [IEd25519Addres initialState, ); - const { totalBalance, availableBalance } = useAddressBalance(network, state.ed25519AddressDetails, null); + const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, null); + const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -44,7 +50,7 @@ export const useEd25519AddressState = (address: Ed25519Address): [IEd25519Addres : { addressDetails: AddressHelper.buildAddress(bech32Hrp, address) }; setState({ - ed25519AddressDetails: addressDetails, + addressDetails, }); }, []); @@ -52,8 +58,10 @@ export const useEd25519AddressState = (address: Ed25519Address): [IEd25519Addres setState({ totalBalance, availableBalance, + addressBasicOutputs, + isBasicOutputsLoading, }); - }, [totalBalance, availableBalance]); + }, [totalBalance, availableBalance, addressBasicOutputs, isBasicOutputsLoading]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts index e569462b6..d0277b00e 100644 --- a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts +++ b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts @@ -1,4 +1,4 @@ -import { ImplicitAccountCreationAddress } from "@iota/sdk-wasm-nova/web"; +import { ImplicitAccountCreationAddress, OutputResponse } from "@iota/sdk-wasm-nova/web"; import { Reducer, useEffect, useReducer } from "react"; import { useLocation, useParams } from "react-router-dom"; import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; @@ -6,18 +6,23 @@ import { useNetworkInfoNova } from "../networkInfo"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; +import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; export interface IImplicitAccountCreationAddressState { - implicitAccountCreationAddressDetails: IAddressDetails | null; + addressDetails: IAddressDetails | null; totalBalance: number | null; availableBalance: number | null; + addressBasicOutputs: OutputResponse[] | null; + isBasicOutputsLoading: boolean; isAssociatedOutputsLoading: boolean; } const initialState = { - implicitAccountCreationAddressDetails: null, + addressDetails: null, totalBalance: null, availableBalance: null, + addressBasicOutputs: null, + isBasicOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -39,7 +44,8 @@ export const useImplicitAccountCreationAddressState = ( initialState, ); - const { totalBalance, availableBalance } = useAddressBalance(network, state.implicitAccountCreationAddressDetails, null); + const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, null); + const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -49,7 +55,7 @@ export const useImplicitAccountCreationAddressState = ( setState({ ...initialState, - implicitAccountCreationAddressDetails: addressDetails, + addressDetails, }); }, []); @@ -57,8 +63,10 @@ export const useImplicitAccountCreationAddressState = ( setState({ totalBalance, availableBalance, + addressBasicOutputs, + isBasicOutputsLoading, }); - }, [totalBalance, availableBalance]); + }, [totalBalance, availableBalance, addressBasicOutputs, isBasicOutputsLoading]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useNftAddressState.ts b/client/src/helpers/nova/hooks/useNftAddressState.ts index aa361ccca..ebc43c8dd 100644 --- a/client/src/helpers/nova/hooks/useNftAddressState.ts +++ b/client/src/helpers/nova/hooks/useNftAddressState.ts @@ -1,5 +1,5 @@ import { Reducer, useEffect, useReducer } from "react"; -import { NftAddress, NftOutput } from "@iota/sdk-wasm-nova/web"; +import { NftAddress, NftOutput, OutputResponse } from "@iota/sdk-wasm-nova/web"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useNftDetails } from "./useNftDetails"; import { useLocation, useParams } from "react-router-dom"; @@ -7,22 +7,27 @@ import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; +import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; export interface INftAddressState { - nftAddressDetails: IAddressDetails | null; + addressDetails: IAddressDetails | null; nftOutput: NftOutput | null; totalBalance: number | null; availableBalance: number | null; + addressBasicOutputs: OutputResponse[] | null; + isBasicOutputsLoading: boolean; isNftDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; } const initialState = { - nftAddressDetails: null, + addressDetails: null, nftOutput: null, isNftDetailsLoading: true, totalBalance: null, availableBalance: null, + addressBasicOutputs: null, + isBasicOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -43,7 +48,8 @@ export const useNftAddressState = (address: NftAddress): [INftAddressState, Reac ); const { nftOutput, isLoading: isNftDetailsLoading } = useNftDetails(network, address.nftId); - const { totalBalance, availableBalance } = useAddressBalance(network, state.nftAddressDetails, nftOutput); + const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, nftOutput); + const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -53,7 +59,7 @@ export const useNftAddressState = (address: NftAddress): [INftAddressState, Reac setState({ ...initialState, - nftAddressDetails: addressDetails, + addressDetails, }); }, []); @@ -63,8 +69,10 @@ export const useNftAddressState = (address: NftAddress): [INftAddressState, Reac totalBalance, availableBalance, isNftDetailsLoading, + addressBasicOutputs, + isBasicOutputsLoading, }); - }, [nftOutput, totalBalance, availableBalance, isNftDetailsLoading]); + }, [nftOutput, totalBalance, availableBalance, isNftDetailsLoading, addressBasicOutputs, isBasicOutputsLoading]); return [state, setState]; }; diff --git a/client/src/models/api/nova/address/IAddressDetailsRequest.ts b/client/src/models/api/nova/address/IAddressDetailsRequest.ts new file mode 100644 index 000000000..0a8aa0b4f --- /dev/null +++ b/client/src/models/api/nova/address/IAddressDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface IAddressDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 address to get the unspent output ids for. + */ + address: string; +} diff --git a/client/src/models/api/nova/address/IAddressDetailsResponse.ts b/client/src/models/api/nova/address/IAddressDetailsResponse.ts new file mode 100644 index 000000000..5a075e38f --- /dev/null +++ b/client/src/models/api/nova/address/IAddressDetailsResponse.ts @@ -0,0 +1,9 @@ +import { OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "../IResponse"; + +export interface IAddressDetailsResponse extends IResponse { + /** + * The outputs data. + */ + outputs?: OutputResponse[]; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index ca2622851..ef66bffbe 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -25,6 +25,8 @@ import { IFoundryRequest } from "~/models/api/nova/foundry/IFoundryRequest"; import { IFoundryResponse } from "~/models/api/nova/foundry/IFoundryResponse"; import { ISearchRequest } from "~/models/api/nova/ISearchRequest"; import { ISearchResponse } from "~/models/api/nova/ISearchResponse"; +import { IAddressDetailsRequest } from "~/models/api/nova/address/IAddressDetailsRequest"; +import { IAddressDetailsResponse } from "~/models/api/nova/address/IAddressDetailsResponse"; /** * Class to handle api communications on nova. @@ -111,6 +113,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/foundry/${request.network}/${request.foundryId}`, "get"); } + /** + * Get the basic outputs details of an address. + * @param request The Address Basic outputs request. + * @returns The Address outputs response + */ + public async basicOutputsDetails(request: IAddressDetailsRequest): Promise { + return this.callApi(`nova/address/outputs/basic/${request.network}/${request.address}`, "get"); + } + /** * Get the associated outputs. * @param request The request to send. From 3a10fe5d644b5b96cc53dbeaa1c5c02eca5ea8ba Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Thu, 15 Feb 2024 10:02:49 +0100 Subject: [PATCH 06/31] Add eslint-disable for unsafe return in novaApiService.ts --- api/src/services/nova/novaApiService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index e27b5773f..7e3936e01 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -1,5 +1,6 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ import { Client, OutputResponse } from "@iota/sdk-nova"; import { ServiceFactory } from "../../factories/serviceFactory"; import logger from "../../logger"; From 4027cb5a49e66e6f5d4d274bb0d4e035335d474d Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Thu, 15 Feb 2024 14:59:20 +0100 Subject: [PATCH 07/31] Add Foundries tab to Accound address page --- .../api/nova/foundry/IFoundriesRequest.ts | 11 +++ .../api/nova/foundry/IFoundriesResponse.ts | 11 +++ api/src/routes.ts | 6 ++ api/src/routes/nova/account/foundries/get.ts | 31 ++++++++ api/src/services/nova/novaApiService.ts | 20 ++++++ .../section/AddressPageTabbedSections.tsx | 49 ++++++++++++- .../account/AccountFoundriesSection.scss | 14 ++++ .../account/AccountFoundriesSection.tsx | 53 ++++++++++++++ .../nova/hooks/useAccountAddressState.ts | 8 +++ .../hooks/useAccountControlledFoundries.ts | 70 +++++++++++++++++++ .../api/nova/foundry/IFoundriesRequest.ts | 11 +++ .../api/nova/foundry/IFoundriesResponse.ts | 9 +++ client/src/services/nova/novaApiClient.ts | 11 +++ 13 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 api/src/models/api/nova/foundry/IFoundriesRequest.ts create mode 100644 api/src/models/api/nova/foundry/IFoundriesResponse.ts create mode 100644 api/src/routes/nova/account/foundries/get.ts create mode 100644 client/src/app/components/nova/address/section/account/AccountFoundriesSection.scss create mode 100644 client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx create mode 100644 client/src/helpers/nova/hooks/useAccountControlledFoundries.ts create mode 100644 client/src/models/api/nova/foundry/IFoundriesRequest.ts create mode 100644 client/src/models/api/nova/foundry/IFoundriesResponse.ts diff --git a/api/src/models/api/nova/foundry/IFoundriesRequest.ts b/api/src/models/api/nova/foundry/IFoundriesRequest.ts new file mode 100644 index 000000000..e19631692 --- /dev/null +++ b/api/src/models/api/nova/foundry/IFoundriesRequest.ts @@ -0,0 +1,11 @@ +export interface IFoundriesRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 account address to get the foundy output ids for. + */ + accountAddress: string; +} diff --git a/api/src/models/api/nova/foundry/IFoundriesResponse.ts b/api/src/models/api/nova/foundry/IFoundriesResponse.ts new file mode 100644 index 000000000..4f6db863b --- /dev/null +++ b/api/src/models/api/nova/foundry/IFoundriesResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { IOutputsResponse } from "@iota/sdk-nova"; +import { IResponse } from "../IResponse"; + +export interface IFoundriesResponse extends IResponse { + /** + * The output ids response. + */ + foundryOutputsResponse?: IOutputsResponse; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index 18a36cf33..c5f2edf37 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -228,6 +228,12 @@ export const routes: IRoute[] = [ func: "post", dataBody: true, }, + { + path: "/nova/account/foundries/:network/:accountAddress", + method: "get", + folder: "nova/account/foundries", + func: "get", + }, { 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" }, ]; diff --git a/api/src/routes/nova/account/foundries/get.ts b/api/src/routes/nova/account/foundries/get.ts new file mode 100644 index 000000000..77b1775c0 --- /dev/null +++ b/api/src/routes/nova/account/foundries/get.ts @@ -0,0 +1,31 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { IFoundriesRequest } from "../../../../models/api/nova/foundry/IFoundriesRequest"; +import { IFoundriesResponse } from "../../../../models/api/nova/foundry/IFoundriesResponse"; + +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"; + +/** + * Get controlled Foundry output id by controller Account address + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: IFoundriesRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.accountAddress, "aliasAddress"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.accountFoundries(request.accountAddress); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 7e3936e01..09d70ff27 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -4,6 +4,7 @@ import { Client, OutputResponse } from "@iota/sdk-nova"; import { ServiceFactory } from "../../factories/serviceFactory"; import logger from "../../logger"; +import { IFoundriesResponse } from "../../models/api/nova/foundry/IFoundriesResponse"; import { IFoundryResponse } from "../../models/api/nova/foundry/IFoundryResponse"; import { IAccountDetailsResponse } from "../../models/api/nova/IAccountDetailsResponse"; import { IAddressDetailsResponse } from "../../models/api/nova/IAddressDetailsResponse"; @@ -156,6 +157,25 @@ export class NovaApiService { } } + /** + * Get controlled Foundry output id by controller Account address + * @param accountAddress The bech32 account address to get the controlled Foundries for. + * @returns The foundry outputs. + */ + public async accountFoundries(accountAddress: string): Promise { + try { + const response = await this.client.foundryOutputIds({ account: accountAddress }); + + if (response) { + return { + foundryOutputsResponse: response, + }; + } + } catch { + return { message: "Foundries output not found" }; + } + } + /** * Get the foundry details. * @param foundryId The foundryId to get the details for. diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 61e796402..a57cf358e 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; +import foundriesMessage from "~assets/modals/stardust/alias/foundries.json"; import TabbedSection from "../../../hoc/TabbedSection"; import AssociatedOutputs from "./association/AssociatedOutputs"; import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; @@ -9,12 +10,18 @@ import { IAnchorAddressState } from "~/helpers/nova/hooks/useAnchorAddressState" import { IEd25519AddressState } from "~/helpers/nova/hooks/useEd25519AddressState"; import AssetsTable from "./native-tokens/AssetsTable"; import { IImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; +import { AddressType } from "@iota/sdk-wasm-nova/web"; +import AccountFoundriesSection from "./account/AccountFoundriesSection"; enum DEFAULT_TABS { NativeTokens = "Native Tokens", AssocOutputs = "Outputs", } +enum ACCOUNT_TABS { + Foundries = "Foundries", +} + const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: number) => ({ [DEFAULT_TABS.AssocOutputs]: { disabled: associatedOutputCount === 0, @@ -30,6 +37,15 @@ const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: num }, }); +const buildAccountAddressTabsOptions = (foundriesCount: number, isAccountFoundriesLoading: boolean) => ({ + [ACCOUNT_TABS.Foundries]: { + disabled: foundriesCount === 0, + hidden: foundriesCount === 0, + isLoading: isAccountFoundriesLoading, + infoContent: foundriesMessage, + }, +}); + interface IAddressPageTabbedSectionsProps { readonly addressState: | IEd25519AddressState @@ -59,11 +75,38 @@ export const AddressPageTabbedSections: React.FC, ]; - const tabEnums = DEFAULT_TABS; + const accountAddressSections = + addressDetails.type === AddressType.Account + ? [ + , + ] + : null; + + let tabEnums = DEFAULT_TABS; const defaultTabsOptions = buildDefaultTabsOptions(tokensCount, outputCount); - const tabOptions = defaultTabsOptions; - const tabbedSections = defaultSections; + let tabOptions = defaultTabsOptions; + let tabbedSections = defaultSections; + switch (addressDetails.type) { + case AddressType.Account: { + tabEnums = { ...DEFAULT_TABS, ...ACCOUNT_TABS }; + tabOptions = { + ...defaultTabsOptions, + ...buildAccountAddressTabsOptions( + (addressState as IAccountAddressState).accountOutput?.foundryCounter ?? 0, + (addressState as IAccountAddressState).isFoundriesLoading, + ), + }; + tabbedSections = [...defaultSections, ...(accountAddressSections ?? [])]; + break; + } + default: { + break; + } + } return ( {tabbedSections} diff --git a/client/src/app/components/nova/address/section/account/AccountFoundriesSection.scss b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.scss new file mode 100644 index 000000000..61a93ab8f --- /dev/null +++ b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.scss @@ -0,0 +1,14 @@ +@import "../../../../../../scss/mixins.scss"; + +.controlled-foundry--card { + border: none !important; + margin-bottom: 48px; + + .field { + margin-bottom: 8px; + + .card--label { + @include font-size(14px, 21px); + } + } +} diff --git a/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx new file mode 100644 index 000000000..0babb9b14 --- /dev/null +++ b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from "react"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import Pagination from "../../../../Pagination"; +import "./AccountFoundriesSection.scss"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; + +interface AccountFoundriesSectionProps { + readonly foundries: string[] | null; +} + +const PAGE_SIZE = 10; + +const AccountFoundriesSection: React.FC = ({ foundries }) => { + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const isMounted = useIsMounted(); + const [page, setPage] = useState([]); + const [pageNumber, setPageNumber] = useState(1); + + // On page change handler + useEffect(() => { + const from = (pageNumber - 1) * PAGE_SIZE; + const to = from + PAGE_SIZE; + if (isMounted && foundries) { + setPage(foundries.slice(from, to)); + } + }, [foundries, pageNumber]); + + return ( +
+
+
+
Foundry Id
+ {page?.map((foundryId, k) => ( +
+ +
+ ))} +
+
+ + setPageNumber(newPage)} + /> +
+ ); +}; + +export default AccountFoundriesSection; diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index ae2f555a1..a92f679db 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -8,6 +8,7 @@ import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; +import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; @@ -15,9 +16,11 @@ export interface IAccountAddressState { totalBalance: number | null; availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; + foundries: string[] | null; isAccountDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; isBasicOutputsLoading: boolean; + isFoundriesLoading: boolean; } const initialState = { @@ -26,9 +29,11 @@ const initialState = { totalBalance: null, availableBalance: null, addressBasicOutputs: null, + foundries: null, isAccountDetailsLoading: true, isAssociatedOutputsLoading: false, isBasicOutputsLoading: false, + isFoundriesLoading: false, }; /** @@ -50,6 +55,7 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres const { accountOutput, isLoading: isAccountDetailsLoading } = useAccountDetails(network, address.accountId); const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, accountOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); + const [foundries, isFoundriesLoading] = useAccountControlledFoundries(network, state.addressDetails); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -69,8 +75,10 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres isAccountDetailsLoading, totalBalance, availableBalance, + foundries, addressBasicOutputs, isBasicOutputsLoading, + isFoundriesLoading, }); }, [accountOutput, totalBalance, availableBalance, addressBasicOutputs, isAccountDetailsLoading, isBasicOutputsLoading]); diff --git a/client/src/helpers/nova/hooks/useAccountControlledFoundries.ts b/client/src/helpers/nova/hooks/useAccountControlledFoundries.ts new file mode 100644 index 000000000..32b7b93c6 --- /dev/null +++ b/client/src/helpers/nova/hooks/useAccountControlledFoundries.ts @@ -0,0 +1,70 @@ +import { OutputType, HexEncodedString, FoundryOutput, Utils } 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 { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +/** + * Fetch Foundries controlled by Account address + * @param network The Network in context + * @param accountAddress The account address + * @returns The account foundries and loading bool. + */ +export function useAccountControlledFoundries(network: string, accountAddress: IAddressDetails | null): [string[] | null, boolean] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [accountFoundries, setAccountFoundries] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (accountAddress) { + const foundries: string[] = []; + // eslint-disable-next-line no-void + void (async () => { + apiClient + .accountFoundries({ + network, + accountAddress: accountAddress.bech32, + }) + .then(async (foundryOutputs) => { + if (foundryOutputs?.foundryOutputsResponse && foundryOutputs?.foundryOutputsResponse?.items.length > 0) { + for (const foundryOutputId of foundryOutputs.foundryOutputsResponse.items) { + const foundryId = await fetchFoundryId(foundryOutputId); + if (foundryId) { + foundries.push(foundryId); + } + } + if (isMounted) { + setAccountFoundries(foundries); + } + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, accountAddress]); + + const fetchFoundryId = async (outputId: HexEncodedString) => { + const foundryId = apiClient.outputDetails({ network, outputId }).then((response) => { + const details = response.output; + if (accountAddress?.hex && !response.error && details?.output?.type === OutputType.Foundry) { + const output = details.output as FoundryOutput; + const serialNumber = output.serialNumber; + const tokenSchemeType = output.tokenScheme.type; + const tokenId = Utils.computeTokenId(accountAddress.hex, serialNumber, tokenSchemeType); + + return tokenId; + } + }); + return foundryId; + }; + + return [accountFoundries, isLoading]; +} diff --git a/client/src/models/api/nova/foundry/IFoundriesRequest.ts b/client/src/models/api/nova/foundry/IFoundriesRequest.ts new file mode 100644 index 000000000..e19631692 --- /dev/null +++ b/client/src/models/api/nova/foundry/IFoundriesRequest.ts @@ -0,0 +1,11 @@ +export interface IFoundriesRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 account address to get the foundy output ids for. + */ + accountAddress: string; +} diff --git a/client/src/models/api/nova/foundry/IFoundriesResponse.ts b/client/src/models/api/nova/foundry/IFoundriesResponse.ts new file mode 100644 index 000000000..00e860042 --- /dev/null +++ b/client/src/models/api/nova/foundry/IFoundriesResponse.ts @@ -0,0 +1,9 @@ +import { IOutputsResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "../IResponse"; + +export interface IFoundriesResponse extends IResponse { + /** + * The output ids response. + */ + foundryOutputsResponse?: IOutputsResponse; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index ef66bffbe..0ad378576 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -27,6 +27,8 @@ import { ISearchRequest } from "~/models/api/nova/ISearchRequest"; import { ISearchResponse } from "~/models/api/nova/ISearchResponse"; import { IAddressDetailsRequest } from "~/models/api/nova/address/IAddressDetailsRequest"; import { IAddressDetailsResponse } from "~/models/api/nova/address/IAddressDetailsResponse"; +import { IFoundriesResponse } from "~/models/api/nova/foundry/IFoundriesResponse"; +import { IFoundriesRequest } from "~/models/api/nova/foundry/IFoundriesRequest"; /** * Class to handle api communications on nova. @@ -113,6 +115,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/foundry/${request.network}/${request.foundryId}`, "get"); } + /** + * Get the foundries controlled by an account address. + * @param request The request to send. + * @returns The response from the request. + */ + public async accountFoundries(request: IFoundriesRequest): Promise { + return this.callApi(`nova/account/foundries/${request.network}/${request.accountAddress}`, "get"); + } + /** * Get the basic outputs details of an address. * @param request The Address Basic outputs request. From b390f0581b1281393ac105bedb0b9137e827a65f Mon Sep 17 00:00:00 2001 From: Bran <52735957+brancoder@users.noreply.github.com> Date: Fri, 16 Feb 2024 14:37:57 +0100 Subject: [PATCH 08/31] Add Foundries tab to Accound address page (#1134) --- .../api/nova/foundry/IFoundriesRequest.ts | 11 +++ .../api/nova/foundry/IFoundriesResponse.ts | 11 +++ api/src/routes.ts | 6 ++ api/src/routes/nova/account/foundries/get.ts | 31 ++++++++ api/src/services/nova/novaApiService.ts | 20 ++++++ .../section/AddressPageTabbedSections.tsx | 49 ++++++++++++- .../account/AccountFoundriesSection.scss | 14 ++++ .../account/AccountFoundriesSection.tsx | 53 ++++++++++++++ .../nova/hooks/useAccountAddressState.ts | 8 +++ .../hooks/useAccountControlledFoundries.ts | 70 +++++++++++++++++++ .../api/nova/foundry/IFoundriesRequest.ts | 11 +++ .../api/nova/foundry/IFoundriesResponse.ts | 9 +++ client/src/services/nova/novaApiClient.ts | 11 +++ 13 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 api/src/models/api/nova/foundry/IFoundriesRequest.ts create mode 100644 api/src/models/api/nova/foundry/IFoundriesResponse.ts create mode 100644 api/src/routes/nova/account/foundries/get.ts create mode 100644 client/src/app/components/nova/address/section/account/AccountFoundriesSection.scss create mode 100644 client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx create mode 100644 client/src/helpers/nova/hooks/useAccountControlledFoundries.ts create mode 100644 client/src/models/api/nova/foundry/IFoundriesRequest.ts create mode 100644 client/src/models/api/nova/foundry/IFoundriesResponse.ts diff --git a/api/src/models/api/nova/foundry/IFoundriesRequest.ts b/api/src/models/api/nova/foundry/IFoundriesRequest.ts new file mode 100644 index 000000000..e19631692 --- /dev/null +++ b/api/src/models/api/nova/foundry/IFoundriesRequest.ts @@ -0,0 +1,11 @@ +export interface IFoundriesRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 account address to get the foundy output ids for. + */ + accountAddress: string; +} diff --git a/api/src/models/api/nova/foundry/IFoundriesResponse.ts b/api/src/models/api/nova/foundry/IFoundriesResponse.ts new file mode 100644 index 000000000..4f6db863b --- /dev/null +++ b/api/src/models/api/nova/foundry/IFoundriesResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { IOutputsResponse } from "@iota/sdk-nova"; +import { IResponse } from "../IResponse"; + +export interface IFoundriesResponse extends IResponse { + /** + * The output ids response. + */ + foundryOutputsResponse?: IOutputsResponse; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index 18a36cf33..c5f2edf37 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -228,6 +228,12 @@ export const routes: IRoute[] = [ func: "post", dataBody: true, }, + { + path: "/nova/account/foundries/:network/:accountAddress", + method: "get", + folder: "nova/account/foundries", + func: "get", + }, { 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" }, ]; diff --git a/api/src/routes/nova/account/foundries/get.ts b/api/src/routes/nova/account/foundries/get.ts new file mode 100644 index 000000000..77b1775c0 --- /dev/null +++ b/api/src/routes/nova/account/foundries/get.ts @@ -0,0 +1,31 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { IFoundriesRequest } from "../../../../models/api/nova/foundry/IFoundriesRequest"; +import { IFoundriesResponse } from "../../../../models/api/nova/foundry/IFoundriesResponse"; + +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"; + +/** + * Get controlled Foundry output id by controller Account address + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: IFoundriesRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.accountAddress, "aliasAddress"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.accountFoundries(request.accountAddress); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 7e3936e01..09d70ff27 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -4,6 +4,7 @@ import { Client, OutputResponse } from "@iota/sdk-nova"; import { ServiceFactory } from "../../factories/serviceFactory"; import logger from "../../logger"; +import { IFoundriesResponse } from "../../models/api/nova/foundry/IFoundriesResponse"; import { IFoundryResponse } from "../../models/api/nova/foundry/IFoundryResponse"; import { IAccountDetailsResponse } from "../../models/api/nova/IAccountDetailsResponse"; import { IAddressDetailsResponse } from "../../models/api/nova/IAddressDetailsResponse"; @@ -156,6 +157,25 @@ export class NovaApiService { } } + /** + * Get controlled Foundry output id by controller Account address + * @param accountAddress The bech32 account address to get the controlled Foundries for. + * @returns The foundry outputs. + */ + public async accountFoundries(accountAddress: string): Promise { + try { + const response = await this.client.foundryOutputIds({ account: accountAddress }); + + if (response) { + return { + foundryOutputsResponse: response, + }; + } + } catch { + return { message: "Foundries output not found" }; + } + } + /** * Get the foundry details. * @param foundryId The foundryId to get the details for. diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 61e796402..a57cf358e 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; +import foundriesMessage from "~assets/modals/stardust/alias/foundries.json"; import TabbedSection from "../../../hoc/TabbedSection"; import AssociatedOutputs from "./association/AssociatedOutputs"; import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; @@ -9,12 +10,18 @@ import { IAnchorAddressState } from "~/helpers/nova/hooks/useAnchorAddressState" import { IEd25519AddressState } from "~/helpers/nova/hooks/useEd25519AddressState"; import AssetsTable from "./native-tokens/AssetsTable"; import { IImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; +import { AddressType } from "@iota/sdk-wasm-nova/web"; +import AccountFoundriesSection from "./account/AccountFoundriesSection"; enum DEFAULT_TABS { NativeTokens = "Native Tokens", AssocOutputs = "Outputs", } +enum ACCOUNT_TABS { + Foundries = "Foundries", +} + const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: number) => ({ [DEFAULT_TABS.AssocOutputs]: { disabled: associatedOutputCount === 0, @@ -30,6 +37,15 @@ const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: num }, }); +const buildAccountAddressTabsOptions = (foundriesCount: number, isAccountFoundriesLoading: boolean) => ({ + [ACCOUNT_TABS.Foundries]: { + disabled: foundriesCount === 0, + hidden: foundriesCount === 0, + isLoading: isAccountFoundriesLoading, + infoContent: foundriesMessage, + }, +}); + interface IAddressPageTabbedSectionsProps { readonly addressState: | IEd25519AddressState @@ -59,11 +75,38 @@ export const AddressPageTabbedSections: React.FC, ]; - const tabEnums = DEFAULT_TABS; + const accountAddressSections = + addressDetails.type === AddressType.Account + ? [ + , + ] + : null; + + let tabEnums = DEFAULT_TABS; const defaultTabsOptions = buildDefaultTabsOptions(tokensCount, outputCount); - const tabOptions = defaultTabsOptions; - const tabbedSections = defaultSections; + let tabOptions = defaultTabsOptions; + let tabbedSections = defaultSections; + switch (addressDetails.type) { + case AddressType.Account: { + tabEnums = { ...DEFAULT_TABS, ...ACCOUNT_TABS }; + tabOptions = { + ...defaultTabsOptions, + ...buildAccountAddressTabsOptions( + (addressState as IAccountAddressState).accountOutput?.foundryCounter ?? 0, + (addressState as IAccountAddressState).isFoundriesLoading, + ), + }; + tabbedSections = [...defaultSections, ...(accountAddressSections ?? [])]; + break; + } + default: { + break; + } + } return ( {tabbedSections} diff --git a/client/src/app/components/nova/address/section/account/AccountFoundriesSection.scss b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.scss new file mode 100644 index 000000000..61a93ab8f --- /dev/null +++ b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.scss @@ -0,0 +1,14 @@ +@import "../../../../../../scss/mixins.scss"; + +.controlled-foundry--card { + border: none !important; + margin-bottom: 48px; + + .field { + margin-bottom: 8px; + + .card--label { + @include font-size(14px, 21px); + } + } +} diff --git a/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx new file mode 100644 index 000000000..0babb9b14 --- /dev/null +++ b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from "react"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import Pagination from "../../../../Pagination"; +import "./AccountFoundriesSection.scss"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; + +interface AccountFoundriesSectionProps { + readonly foundries: string[] | null; +} + +const PAGE_SIZE = 10; + +const AccountFoundriesSection: React.FC = ({ foundries }) => { + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const isMounted = useIsMounted(); + const [page, setPage] = useState([]); + const [pageNumber, setPageNumber] = useState(1); + + // On page change handler + useEffect(() => { + const from = (pageNumber - 1) * PAGE_SIZE; + const to = from + PAGE_SIZE; + if (isMounted && foundries) { + setPage(foundries.slice(from, to)); + } + }, [foundries, pageNumber]); + + return ( +
+
+
+
Foundry Id
+ {page?.map((foundryId, k) => ( +
+ +
+ ))} +
+
+ + setPageNumber(newPage)} + /> +
+ ); +}; + +export default AccountFoundriesSection; diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index ae2f555a1..a92f679db 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -8,6 +8,7 @@ import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; +import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; @@ -15,9 +16,11 @@ export interface IAccountAddressState { totalBalance: number | null; availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; + foundries: string[] | null; isAccountDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; isBasicOutputsLoading: boolean; + isFoundriesLoading: boolean; } const initialState = { @@ -26,9 +29,11 @@ const initialState = { totalBalance: null, availableBalance: null, addressBasicOutputs: null, + foundries: null, isAccountDetailsLoading: true, isAssociatedOutputsLoading: false, isBasicOutputsLoading: false, + isFoundriesLoading: false, }; /** @@ -50,6 +55,7 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres const { accountOutput, isLoading: isAccountDetailsLoading } = useAccountDetails(network, address.accountId); const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, accountOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); + const [foundries, isFoundriesLoading] = useAccountControlledFoundries(network, state.addressDetails); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -69,8 +75,10 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres isAccountDetailsLoading, totalBalance, availableBalance, + foundries, addressBasicOutputs, isBasicOutputsLoading, + isFoundriesLoading, }); }, [accountOutput, totalBalance, availableBalance, addressBasicOutputs, isAccountDetailsLoading, isBasicOutputsLoading]); diff --git a/client/src/helpers/nova/hooks/useAccountControlledFoundries.ts b/client/src/helpers/nova/hooks/useAccountControlledFoundries.ts new file mode 100644 index 000000000..32b7b93c6 --- /dev/null +++ b/client/src/helpers/nova/hooks/useAccountControlledFoundries.ts @@ -0,0 +1,70 @@ +import { OutputType, HexEncodedString, FoundryOutput, Utils } 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 { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +/** + * Fetch Foundries controlled by Account address + * @param network The Network in context + * @param accountAddress The account address + * @returns The account foundries and loading bool. + */ +export function useAccountControlledFoundries(network: string, accountAddress: IAddressDetails | null): [string[] | null, boolean] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [accountFoundries, setAccountFoundries] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (accountAddress) { + const foundries: string[] = []; + // eslint-disable-next-line no-void + void (async () => { + apiClient + .accountFoundries({ + network, + accountAddress: accountAddress.bech32, + }) + .then(async (foundryOutputs) => { + if (foundryOutputs?.foundryOutputsResponse && foundryOutputs?.foundryOutputsResponse?.items.length > 0) { + for (const foundryOutputId of foundryOutputs.foundryOutputsResponse.items) { + const foundryId = await fetchFoundryId(foundryOutputId); + if (foundryId) { + foundries.push(foundryId); + } + } + if (isMounted) { + setAccountFoundries(foundries); + } + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, accountAddress]); + + const fetchFoundryId = async (outputId: HexEncodedString) => { + const foundryId = apiClient.outputDetails({ network, outputId }).then((response) => { + const details = response.output; + if (accountAddress?.hex && !response.error && details?.output?.type === OutputType.Foundry) { + const output = details.output as FoundryOutput; + const serialNumber = output.serialNumber; + const tokenSchemeType = output.tokenScheme.type; + const tokenId = Utils.computeTokenId(accountAddress.hex, serialNumber, tokenSchemeType); + + return tokenId; + } + }); + return foundryId; + }; + + return [accountFoundries, isLoading]; +} diff --git a/client/src/models/api/nova/foundry/IFoundriesRequest.ts b/client/src/models/api/nova/foundry/IFoundriesRequest.ts new file mode 100644 index 000000000..e19631692 --- /dev/null +++ b/client/src/models/api/nova/foundry/IFoundriesRequest.ts @@ -0,0 +1,11 @@ +export interface IFoundriesRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 account address to get the foundy output ids for. + */ + accountAddress: string; +} diff --git a/client/src/models/api/nova/foundry/IFoundriesResponse.ts b/client/src/models/api/nova/foundry/IFoundriesResponse.ts new file mode 100644 index 000000000..00e860042 --- /dev/null +++ b/client/src/models/api/nova/foundry/IFoundriesResponse.ts @@ -0,0 +1,9 @@ +import { IOutputsResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "../IResponse"; + +export interface IFoundriesResponse extends IResponse { + /** + * The output ids response. + */ + foundryOutputsResponse?: IOutputsResponse; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index ef66bffbe..0ad378576 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -27,6 +27,8 @@ import { ISearchRequest } from "~/models/api/nova/ISearchRequest"; import { ISearchResponse } from "~/models/api/nova/ISearchResponse"; import { IAddressDetailsRequest } from "~/models/api/nova/address/IAddressDetailsRequest"; import { IAddressDetailsResponse } from "~/models/api/nova/address/IAddressDetailsResponse"; +import { IFoundriesResponse } from "~/models/api/nova/foundry/IFoundriesResponse"; +import { IFoundriesRequest } from "~/models/api/nova/foundry/IFoundriesRequest"; /** * Class to handle api communications on nova. @@ -113,6 +115,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/foundry/${request.network}/${request.foundryId}`, "get"); } + /** + * Get the foundries controlled by an account address. + * @param request The request to send. + * @returns The response from the request. + */ + public async accountFoundries(request: IFoundriesRequest): Promise { + return this.callApi(`nova/account/foundries/${request.network}/${request.accountAddress}`, "get"); + } + /** * Get the basic outputs details of an address. * @param request The Address Basic outputs request. From b77d8a995e3fbae1ac5ced0f6bf3534e2a027593 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Fri, 16 Feb 2024 15:29:32 +0100 Subject: [PATCH 09/31] feat: add block issuance tab to account page --- api/src/models/api/nova/ICongestionRequest.ts | 11 +++++ .../models/api/nova/ICongestionResponse.ts | 11 +++++ api/src/routes.ts | 6 +++ api/src/routes/nova/account/congestion/get.ts | 30 ++++++++++++ api/src/services/nova/novaApiService.ts | 20 ++++++++ .../section/AddressPageTabbedSections.tsx | 21 ++++++-- .../account/AccountBlockIssuanceSection.scss | 14 ++++++ .../account/AccountBlockIssuanceSection.tsx | 46 ++++++++++++++++++ .../src/assets/modals/nova/account/bic.json | 11 +++++ .../nova/hooks/useAccountAddressState.ts | 48 +++++++++++++++++-- .../nova/hooks/useAccountCongestion.ts | 48 +++++++++++++++++++ .../src/models/api/nova/ICongestionRequest.ts | 11 +++++ .../models/api/nova/ICongestionResponse.ts | 11 +++++ client/src/services/nova/novaApiClient.ts | 11 +++++ 14 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 api/src/models/api/nova/ICongestionRequest.ts create mode 100644 api/src/models/api/nova/ICongestionResponse.ts create mode 100644 api/src/routes/nova/account/congestion/get.ts create mode 100644 client/src/app/components/nova/address/section/account/AccountBlockIssuanceSection.scss create mode 100644 client/src/app/components/nova/address/section/account/AccountBlockIssuanceSection.tsx create mode 100644 client/src/assets/modals/nova/account/bic.json create mode 100644 client/src/helpers/nova/hooks/useAccountCongestion.ts create mode 100644 client/src/models/api/nova/ICongestionRequest.ts create mode 100644 client/src/models/api/nova/ICongestionResponse.ts diff --git a/api/src/models/api/nova/ICongestionRequest.ts b/api/src/models/api/nova/ICongestionRequest.ts new file mode 100644 index 000000000..00e845db6 --- /dev/null +++ b/api/src/models/api/nova/ICongestionRequest.ts @@ -0,0 +1,11 @@ +export interface ICongestionRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the congestion for. + */ + accountId: string; +} diff --git a/api/src/models/api/nova/ICongestionResponse.ts b/api/src/models/api/nova/ICongestionResponse.ts new file mode 100644 index 000000000..9e7fff964 --- /dev/null +++ b/api/src/models/api/nova/ICongestionResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { CongestionResponse } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface ICongestionResponse extends IResponse { + /** + * The Account Congestion. + */ + congestion?: CongestionResponse; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index c5f2edf37..d919d1f48 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -234,6 +234,12 @@ export const routes: IRoute[] = [ folder: "nova/account/foundries", func: "get", }, + { + path: "/nova/account/congestion/:network/:accountId", + method: "get", + folder: "nova/account/congestion", + func: "get", + }, { 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" }, ]; diff --git a/api/src/routes/nova/account/congestion/get.ts b/api/src/routes/nova/account/congestion/get.ts new file mode 100644 index 000000000..4ab680766 --- /dev/null +++ b/api/src/routes/nova/account/congestion/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { ICongestionRequest } from "../../../../models/api/nova/ICongestionRequest"; +import { ICongestionResponse } from "../../../../models/api/nova/ICongestionResponse"; +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"; + +/** + * Get Congestion for Account address + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: ICongestionRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.accountId, "accountId"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.getAccountCongestion(request.accountId); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 09d70ff27..48d25f8fe 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -11,6 +11,7 @@ import { IAddressDetailsResponse } from "../../models/api/nova/IAddressDetailsRe import { IAnchorDetailsResponse } from "../../models/api/nova/IAnchorDetailsResponse"; import { IBlockDetailsResponse } from "../../models/api/nova/IBlockDetailsResponse"; import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; +import { ICongestionResponse } from "../../models/api/nova/ICongestionResponse"; import { INftDetailsResponse } from "../../models/api/nova/INftDetailsResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; import { IRewardsResponse } from "../../models/api/nova/IRewardsResponse"; @@ -250,6 +251,25 @@ export class NovaApiService { }; } + /** + * Get Congestion for Account + * @param accountId The account address to get the congestion for. + * @returns The Congestion. + */ + public async getAccountCongestion(accountId: string): Promise { + try { + const response = await this.client.getAccountCongestion(accountId); + + if (response) { + return { + congestion: response, + }; + } + } catch { + return { message: "Account congestion not found" }; + } + } + /** * Get the output mana rewards. * @param outputId The outputId to get the rewards for. diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index a57cf358e..c1ead9341 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; import foundriesMessage from "~assets/modals/stardust/alias/foundries.json"; +import bicMessage from "~assets/modals/nova/account/bic.json"; import TabbedSection from "../../../hoc/TabbedSection"; import AssociatedOutputs from "./association/AssociatedOutputs"; import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; @@ -12,6 +13,7 @@ import AssetsTable from "./native-tokens/AssetsTable"; import { IImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; import { AddressType } from "@iota/sdk-wasm-nova/web"; import AccountFoundriesSection from "./account/AccountFoundriesSection"; +import AccountBlockIssuanceSection from "./account/AccountBlockIssuanceSection"; enum DEFAULT_TABS { NativeTokens = "Native Tokens", @@ -19,6 +21,7 @@ enum DEFAULT_TABS { } enum ACCOUNT_TABS { + BlockIssuance = "Block Issuance", Foundries = "Foundries", } @@ -37,13 +40,18 @@ const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: num }, }); -const buildAccountAddressTabsOptions = (foundriesCount: number, isAccountFoundriesLoading: boolean) => ({ +const buildAccountAddressTabsOptions = (isBlockIssuer: boolean, foundriesCount: number, isAccountFoundriesLoading: boolean) => ({ [ACCOUNT_TABS.Foundries]: { disabled: foundriesCount === 0, hidden: foundriesCount === 0, isLoading: isAccountFoundriesLoading, infoContent: foundriesMessage, }, + [ACCOUNT_TABS.BlockIssuance]: { + disabled: !isBlockIssuer, + hidden: !isBlockIssuer, + infoContent: bicMessage, + }, }); interface IAddressPageTabbedSectionsProps { @@ -78,6 +86,11 @@ export const AddressPageTabbedSections: React.FC, = ({ blockIssuerFeature, congestion }) => { + return ( +
+
+
+
Current Slot
+
{congestion?.slot}
+
+
+
Block Issuance Credit
+
{congestion?.blockIssuanceCredits.toString()}
+
+
+
Referenced Mana Cost
+
{congestion?.referenceManaCost.toString()}
+
+
+
Expiry Slot
+
{blockIssuerFeature?.expirySlot}
+
+
+ {blockIssuerFeature?.blockIssuerKeys.map((key) => ( + + Public Key: +
+ +
+
+ ))} +
+
+
+ ); +}; + +export default AccountBlockIssuanceSection; diff --git a/client/src/assets/modals/nova/account/bic.json b/client/src/assets/modals/nova/account/bic.json new file mode 100644 index 000000000..3b875279d --- /dev/null +++ b/client/src/assets/modals/nova/account/bic.json @@ -0,0 +1,11 @@ +{ + "title": "Block Issuance Credit", + "description": "

(BIC) is the form of Mana used as an anti-spam mechanism to the block issuance process.

", + "links": [ + { + "label": "Read more", + "href": "https://wiki.iota.org/learn/protocols/iota2.0/core-concepts/mana/#block-issuance-credits-bic", + "isExternal": true + } + ] +} diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index a92f679db..09c5ec62d 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -1,5 +1,12 @@ import { Reducer, useEffect, useReducer } from "react"; -import { AccountAddress, AccountOutput, OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { + AccountAddress, + AccountOutput, + BlockIssuerFeature, + CongestionResponse, + FeatureType, + OutputResponse, +} from "@iota/sdk-wasm-nova/web"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useAccountDetails } from "./useAccountDetails"; import { useLocation, useParams } from "react-router-dom"; @@ -9,18 +16,22 @@ import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; +import { useAccountCongestion } from "./useAccountCongestion"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; accountOutput: AccountOutput | null; totalBalance: number | null; availableBalance: number | null; + blockIssuerFeature: BlockIssuerFeature | null; addressBasicOutputs: OutputResponse[] | null; foundries: string[] | null; + congestion: CongestionResponse | null; isAccountDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; isBasicOutputsLoading: boolean; isFoundriesLoading: boolean; + isCongestionLoading: boolean; } const initialState = { @@ -28,12 +39,15 @@ const initialState = { accountOutput: null, totalBalance: null, availableBalance: null, + blockIssuerFeature: null, addressBasicOutputs: null, foundries: null, + congestion: null, isAccountDetailsLoading: true, isAssociatedOutputsLoading: false, isBasicOutputsLoading: false, isFoundriesLoading: false, + isCongestionLoading: false, }; /** @@ -56,6 +70,7 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, accountOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); const [foundries, isFoundriesLoading] = useAccountControlledFoundries(network, state.addressDetails); + const { congestion, isLoading: isCongestionLoading } = useAccountCongestion(network, state.addressDetails?.hex ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -70,17 +85,42 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres }, []); useEffect(() => { - setState({ + let updatedState: Partial = { accountOutput, isAccountDetailsLoading, totalBalance, availableBalance, foundries, + congestion, addressBasicOutputs, isBasicOutputsLoading, isFoundriesLoading, - }); - }, [accountOutput, totalBalance, availableBalance, addressBasicOutputs, isAccountDetailsLoading, isBasicOutputsLoading]); + isCongestionLoading, + }; + + if (accountOutput && !state.blockIssuerFeature) { + const blockIssuerFeature = accountOutput?.features?.find( + (feature) => feature.type === FeatureType.BlockIssuer, + ) as BlockIssuerFeature; + if (blockIssuerFeature) { + updatedState = { + ...updatedState, + blockIssuerFeature, + }; + } + } + + setState(updatedState); + }, [ + accountOutput, + totalBalance, + availableBalance, + addressBasicOutputs, + congestion, + isAccountDetailsLoading, + isBasicOutputsLoading, + isCongestionLoading, + ]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useAccountCongestion.ts b/client/src/helpers/nova/hooks/useAccountCongestion.ts new file mode 100644 index 000000000..7ca8eafd9 --- /dev/null +++ b/client/src/helpers/nova/hooks/useAccountCongestion.ts @@ -0,0 +1,48 @@ +import { CongestionResponse } from "@iota/sdk-wasm-nova/web"; +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"; + +/** + * Fetch account congestion + * @param network The Network in context + * @param accountId The account id + * @returns The output response and loading bool. + */ +export function useAccountCongestion( + network: string, + accountId: string | null, +): { congestion: CongestionResponse | null; isLoading: boolean } { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [congestion, setAccountCongestion] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (accountId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .getAccountCongestion({ + network, + accountId, + }) + .then((response) => { + if (!response?.error && isMounted) { + setAccountCongestion(response.congestion ?? null); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, accountId]); + + return { congestion, isLoading }; +} diff --git a/client/src/models/api/nova/ICongestionRequest.ts b/client/src/models/api/nova/ICongestionRequest.ts new file mode 100644 index 000000000..00e845db6 --- /dev/null +++ b/client/src/models/api/nova/ICongestionRequest.ts @@ -0,0 +1,11 @@ +export interface ICongestionRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the congestion for. + */ + accountId: string; +} diff --git a/client/src/models/api/nova/ICongestionResponse.ts b/client/src/models/api/nova/ICongestionResponse.ts new file mode 100644 index 000000000..cb176a6f3 --- /dev/null +++ b/client/src/models/api/nova/ICongestionResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { CongestionResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; + +export interface ICongestionResponse extends IResponse { + /** + * The Account Congestion. + */ + congestion?: CongestionResponse; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 0ad378576..859be2fcc 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 { ICongestionRequest } from "~/models/api/nova/ICongestionRequest"; +import { ICongestionResponse } from "~/models/api/nova/ICongestionResponse"; /** * Class to handle api communications on nova. @@ -146,6 +148,15 @@ export class NovaApiClient extends ApiClient { ); } + /** + * Get the account congestion. + * @param request The request to send. + * @returns The response from the request. + */ + public async getAccountCongestion(request: ICongestionRequest): Promise { + return this.callApi(`nova/account/congestion/${request.network}/${request.accountId}`, "get"); + } + /** * Get the output mana rewards. * @param request The request to send. From 0f0b1c5c77388337d2e897a6ec6a4ff00572c8cb Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Fri, 16 Feb 2024 15:36:58 +0100 Subject: [PATCH 10/31] fix: validation check in foundries endpoint --- api/src/routes/nova/account/foundries/get.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/routes/nova/account/foundries/get.ts b/api/src/routes/nova/account/foundries/get.ts index 77b1775c0..d5f210bbc 100644 --- a/api/src/routes/nova/account/foundries/get.ts +++ b/api/src/routes/nova/account/foundries/get.ts @@ -1,7 +1,6 @@ import { ServiceFactory } from "../../../../factories/serviceFactory"; import { IFoundriesRequest } from "../../../../models/api/nova/foundry/IFoundriesRequest"; import { IFoundriesResponse } from "../../../../models/api/nova/foundry/IFoundriesResponse"; - import { IConfiguration } from "../../../../models/configuration/IConfiguration"; import { NOVA } from "../../../../models/db/protocolVersion"; import { NetworkService } from "../../../../services/networkService"; @@ -18,7 +17,7 @@ export async function get(config: IConfiguration, request: IFoundriesRequest): P const networkService = ServiceFactory.get("network"); const networks = networkService.networkNames(); ValidationHelper.oneOf(request.network, networks, "network"); - ValidationHelper.string(request.accountAddress, "aliasAddress"); + ValidationHelper.string(request.accountAddress, "accountAddress"); const networkConfig = networkService.get(request.network); From 9db3390889bf048085dd5276cf9d01293a3577c2 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Fri, 16 Feb 2024 15:51:41 +0100 Subject: [PATCH 11/31] fix: component imports --- .../app/components/nova/address/AccountAddressView.tsx | 2 +- .../app/components/nova/address/AnchorAddressView.tsx | 2 +- .../app/components/nova/address/Ed25519AddressView.tsx | 2 +- .../nova/address/ImplicitAccountCreationAddressView.tsx | 2 +- .../src/app/components/nova/address/NftAddressView.tsx | 2 +- .../address/section/account/AccountFoundriesSection.tsx | 2 +- .../nova/address/section/native-tokens/Asset.tsx | 9 ++++----- .../nova/address/section/native-tokens/AssetsTable.tsx | 2 +- 8 files changed, 11 insertions(+), 12 deletions(-) diff --git a/client/src/app/components/nova/address/AccountAddressView.tsx b/client/src/app/components/nova/address/AccountAddressView.tsx index 248b7c109..51ec141d5 100644 --- a/client/src/app/components/nova/address/AccountAddressView.tsx +++ b/client/src/app/components/nova/address/AccountAddressView.tsx @@ -1,7 +1,7 @@ import { AccountAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useAccountAddressState } from "~/helpers/nova/hooks/useAccountAddressState"; -import Spinner from "../../Spinner"; +import Spinner from "~/app/components/Spinner"; import Bech32Address from "../../nova/address/Bech32Address"; import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; import AddressBalance from "./AddressBalance"; diff --git a/client/src/app/components/nova/address/AnchorAddressView.tsx b/client/src/app/components/nova/address/AnchorAddressView.tsx index 3c30352f7..c07fd99d2 100644 --- a/client/src/app/components/nova/address/AnchorAddressView.tsx +++ b/client/src/app/components/nova/address/AnchorAddressView.tsx @@ -1,7 +1,7 @@ import { AnchorAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useAnchorAddressState } from "~/helpers/nova/hooks/useAnchorAddressState"; -import Spinner from "../../Spinner"; +import Spinner from "~/app/components/Spinner"; import AddressBalance from "./AddressBalance"; import Bech32Address from "./Bech32Address"; import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; diff --git a/client/src/app/components/nova/address/Ed25519AddressView.tsx b/client/src/app/components/nova/address/Ed25519AddressView.tsx index 6c89a5717..375adcfa8 100644 --- a/client/src/app/components/nova/address/Ed25519AddressView.tsx +++ b/client/src/app/components/nova/address/Ed25519AddressView.tsx @@ -4,7 +4,7 @@ import { useEd25519AddressState } from "~/helpers/nova/hooks/useEd25519AddressSt import AddressBalance from "./AddressBalance"; import Bech32Address from "./Bech32Address"; import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; -import Spinner from "../../Spinner"; +import Spinner from "~/app/components/Spinner"; interface Ed25519AddressViewProps { ed25519Address: Ed25519Address; diff --git a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx index 05e0256ab..60162fdc5 100644 --- a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx +++ b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; import AddressBalance from "./AddressBalance"; import Bech32Address from "./Bech32Address"; -import Spinner from "../../Spinner"; +import Spinner from "~/app/components/Spinner"; import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; interface ImplicitAccountCreationAddressViewProps { diff --git a/client/src/app/components/nova/address/NftAddressView.tsx b/client/src/app/components/nova/address/NftAddressView.tsx index 51431739e..3646e9ddb 100644 --- a/client/src/app/components/nova/address/NftAddressView.tsx +++ b/client/src/app/components/nova/address/NftAddressView.tsx @@ -1,7 +1,7 @@ import { NftAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useNftAddressState } from "~/helpers/nova/hooks/useNftAddressState"; -import Spinner from "../../Spinner"; +import Spinner from "~/app/components/Spinner"; import AddressBalance from "./AddressBalance"; import Bech32Address from "./Bech32Address"; import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; diff --git a/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx index 0babb9b14..d0fd20aaa 100644 --- a/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx +++ b/client/src/app/components/nova/address/section/account/AccountFoundriesSection.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { useIsMounted } from "~helpers/hooks/useIsMounted"; -import Pagination from "../../../../Pagination"; +import Pagination from "~/app/components/Pagination"; import "./AccountFoundriesSection.scss"; import TruncatedId from "~/app/components/stardust/TruncatedId"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; diff --git a/client/src/app/components/nova/address/section/native-tokens/Asset.tsx b/client/src/app/components/nova/address/section/native-tokens/Asset.tsx index c22f40bcf..426614915 100644 --- a/client/src/app/components/nova/address/section/native-tokens/Asset.tsx +++ b/client/src/app/components/nova/address/section/native-tokens/Asset.tsx @@ -1,14 +1,13 @@ /* eslint-disable jsdoc/require-param */ /* eslint-disable jsdoc/require-returns */ -import { FoundryOutput, MetadataFeature, FeatureType, Irc30Metadata } from "@iota/sdk-wasm-nova/web"; +import { FoundryOutput, MetadataFeature, FeatureType, Irc30Metadata, hexToUtf8 } from "@iota/sdk-wasm-nova/web"; import { Validator as JsonSchemaValidator } from "jsonschema"; import React, { ReactElement, useEffect, useState } from "react"; import { AssetProps } from "./AssetProps"; import tokenSchemeIRC30 from "~assets/schemas/token-schema-IRC30.json"; -import { useFoundryDetails } from "~helpers/stardust/hooks/useFoundryDetails"; +import { useFoundryDetails } from "~helpers/nova/hooks/useFoundryDetails"; import { useTokenRegistryNativeTokenCheck } from "~helpers/stardust/hooks/useTokenRegistryNativeTokenCheck"; -import { Converter } from "~helpers/stardust/convertUtils"; -import Spinner from "../../../../Spinner"; +import Spinner from "~/app/components/Spinner"; import TruncatedId from "~/app/components/stardust/TruncatedId"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; @@ -37,7 +36,7 @@ const Asset: React.FC = ({ tableFormat, token }) => { const validator = new JsonSchemaValidator(); try { - const tokenInfo = JSON.parse(Converter.hexToUtf8(metadata.data)) as Irc30Metadata; + const tokenInfo = JSON.parse(hexToUtf8(metadata.data)) as Irc30Metadata; const result = validator.validate(tokenInfo, tokenSchemeIRC30); if (result.valid) { diff --git a/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx index 99e13dc7a..bce41a33c 100644 --- a/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx +++ b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx @@ -1,7 +1,7 @@ import { OutputType, OutputResponse, CommonOutput, INativeToken, FeatureType, NativeTokenFeature } from "@iota/sdk-wasm-nova/web"; import React, { useEffect, useState } from "react"; import Asset from "./Asset"; -import Pagination from "../../../../Pagination"; +import Pagination from "~/app/components/Pagination"; import { plainToInstance } from "class-transformer"; import "./AssetsTable.scss"; From fa78224ad892013aa8773a1829d5423848f1ab14 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Fri, 16 Feb 2024 16:27:21 +0100 Subject: [PATCH 12/31] fix: add is congestion loading --- .../nova/address/section/AddressPageTabbedSections.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index c1ead9341..40d1c0f2e 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -40,7 +40,12 @@ const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: num }, }); -const buildAccountAddressTabsOptions = (isBlockIssuer: boolean, foundriesCount: number, isAccountFoundriesLoading: boolean) => ({ +const buildAccountAddressTabsOptions = ( + isBlockIssuer: boolean, + isCongestionLoading: boolean, + foundriesCount: number, + isAccountFoundriesLoading: boolean, +) => ({ [ACCOUNT_TABS.Foundries]: { disabled: foundriesCount === 0, hidden: foundriesCount === 0, @@ -50,6 +55,7 @@ const buildAccountAddressTabsOptions = (isBlockIssuer: boolean, foundriesCount: [ACCOUNT_TABS.BlockIssuance]: { disabled: !isBlockIssuer, hidden: !isBlockIssuer, + isLoading: isCongestionLoading, infoContent: bicMessage, }, }); @@ -111,6 +117,7 @@ export const AddressPageTabbedSections: React.FC Date: Sat, 17 Feb 2024 18:43:31 +0100 Subject: [PATCH 13/31] fix: add state tab for Anchor Address --- .../section/AddressPageTabbedSections.tsx | 35 ++++++++++++++++++ .../section/anchor/AnchorStateSection.tsx | 36 +++++++++++++++++++ .../section/native-tokens/AssetsTable.tsx | 10 +++--- 3 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 client/src/app/components/nova/address/section/anchor/AnchorStateSection.tsx diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 40d1c0f2e..664d36d8c 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; import foundriesMessage from "~assets/modals/stardust/alias/foundries.json"; +import stateMessage from "~assets/modals/stardust/alias/state.json"; import bicMessage from "~assets/modals/nova/account/bic.json"; import TabbedSection from "../../../hoc/TabbedSection"; import AssociatedOutputs from "./association/AssociatedOutputs"; @@ -14,6 +15,7 @@ import { IImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useIm import { AddressType } from "@iota/sdk-wasm-nova/web"; import AccountFoundriesSection from "./account/AccountFoundriesSection"; import AccountBlockIssuanceSection from "./account/AccountBlockIssuanceSection"; +import AnchorStateSection from "./anchor/AnchorStateSection"; enum DEFAULT_TABS { NativeTokens = "Native Tokens", @@ -25,6 +27,10 @@ enum ACCOUNT_TABS { Foundries = "Foundries", } +enum ANCHOR_TABS { + State = "State", +} + const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: number) => ({ [DEFAULT_TABS.AssocOutputs]: { disabled: associatedOutputCount === 0, @@ -60,6 +66,15 @@ const buildAccountAddressTabsOptions = ( }, }); +const buildAnchorAddressTabsOptions = (isAnchorStateTabDisabled: boolean, isAnchorDetailsLoading: boolean) => ({ + [ANCHOR_TABS.State]: { + disabled: isAnchorStateTabDisabled, + hidden: isAnchorStateTabDisabled, + isLoading: isAnchorDetailsLoading, + infoContent: stateMessage, + }, +}); + interface IAddressPageTabbedSectionsProps { readonly addressState: | IEd25519AddressState @@ -104,6 +119,16 @@ export const AddressPageTabbedSections: React.FC, + ] + : null; + let tabEnums = DEFAULT_TABS; const defaultTabsOptions = buildDefaultTabsOptions(tokensCount, outputCount); let tabOptions = defaultTabsOptions; @@ -125,6 +150,16 @@ export const AddressPageTabbedSections: React.FC = ({ output }) => { + // const stateMetadataFeature = output?.features.find(feature => feature.type === FeatureType.StateMetadata) as StateMetadataFeature; + + return ( +
+
+
+
State Index
+
+ {output?.stateIndex} +
+
+ {/* {stateMetadata && ( +
+
State Metadata
+
+ +
+
+ )} */} +
+
+ ); +}; + +export default AnchorStateSection; diff --git a/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx index bce41a33c..011e35dcf 100644 --- a/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx +++ b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx @@ -1,4 +1,4 @@ -import { OutputType, OutputResponse, CommonOutput, INativeToken, FeatureType, NativeTokenFeature } from "@iota/sdk-wasm-nova/web"; +import { OutputType, OutputResponse, CommonOutput, NativeToken, FeatureType, NativeTokenFeature } from "@iota/sdk-wasm-nova/web"; import React, { useEffect, useState } from "react"; import Asset from "./Asset"; import Pagination from "~/app/components/Pagination"; @@ -13,8 +13,8 @@ interface AssetsTableProps { const TOKEN_PAGE_SIZE: number = 10; const AssetsTable: React.FC = ({ outputs, setTokensCount }) => { - const [tokens, setTokens] = useState(); - const [currentPage, setCurrentPage] = useState([]); + const [tokens, setTokens] = useState(); + const [currentPage, setCurrentPage] = useState([]); const [pageNumber, setPageNumber] = useState(1); useEffect(() => { @@ -23,7 +23,7 @@ const AssetsTable: React.FC = ({ outputs, setTokensCount }) => } if (outputs) { - const theTokens: INativeToken[] = []; + const theTokens: NativeToken[] = []; for (const outputResponse of outputs) { const output = outputResponse.output as CommonOutput; if (output.type === OutputType.Basic || output.type === OutputType.Foundry) { @@ -102,7 +102,7 @@ const AssetsTable: React.FC = ({ outputs, setTokensCount }) => }; AssetsTable.defaultProps = { - setTokenCount: undefined, + setTokensCount: undefined, }; export default AssetsTable; From 1d9ef02240e89476763aaa5fefebd7e9798d0c6e Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Wed, 21 Feb 2024 13:26:12 +0100 Subject: [PATCH 14/31] fix: add state metadata feature --- .github/workflows/nova-build-temp.yaml | 2 +- api/src/initServices.ts | 2 +- .../address/section/anchor/AnchorStateSection.tsx | 15 ++++++++------- setup_nova.sh | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/nova-build-temp.yaml b/.github/workflows/nova-build-temp.yaml index 2508da903..7b718a706 100644 --- a/.github/workflows/nova-build-temp.yaml +++ b/.github/workflows/nova-build-temp.yaml @@ -6,7 +6,7 @@ on: TARGET_COMMIT: description: "Target Commit Hash for the SDK" required: false - default: "8f0ff5e1e899a0d960ddfea09237739a88c3bcf1" + default: "fc9f0f56bb5cfc146993e53aa9656ded220734e1" environment: type: choice description: "Select the environment to deploy to" diff --git a/api/src/initServices.ts b/api/src/initServices.ts index 6a9f02792..7084818b4 100644 --- a/api/src/initServices.ts +++ b/api/src/initServices.ts @@ -211,7 +211,7 @@ function initNovaServices(networkConfig: INetwork): void { logger.verbose(`Initializing Nova services for ${networkConfig.network}`); const novaClientParams: INovaClientOptions = { - primaryNode: networkConfig.provider, + primaryNodes: [networkConfig.provider], }; if (networkConfig.permaNodeEndpoint) { diff --git a/client/src/app/components/nova/address/section/anchor/AnchorStateSection.tsx b/client/src/app/components/nova/address/section/anchor/AnchorStateSection.tsx index a720af001..ffc910a9f 100644 --- a/client/src/app/components/nova/address/section/anchor/AnchorStateSection.tsx +++ b/client/src/app/components/nova/address/section/anchor/AnchorStateSection.tsx @@ -1,5 +1,6 @@ -import { AnchorOutput } from "@iota/sdk-wasm-nova/web"; +import { AnchorOutput, FeatureType, StateMetadataFeature } from "@iota/sdk-wasm-nova/web"; import React from "react"; +import DataToggle from "~/app/components/DataToggle"; interface AnchorStateSectionProps { /** @@ -9,7 +10,7 @@ interface AnchorStateSectionProps { } const AnchorStateSection: React.FC = ({ output }) => { - // const stateMetadataFeature = output?.features.find(feature => feature.type === FeatureType.StateMetadata) as StateMetadataFeature; + const stateMetadata = output?.features?.find((feature) => feature.type === FeatureType.StateMetadata) as StateMetadataFeature; return (
@@ -20,14 +21,14 @@ const AnchorStateSection: React.FC = ({ output }) => { {output?.stateIndex}
- {/* {stateMetadata && ( -
-
State Metadata
+ {Object.entries(stateMetadata.entries).map(([key, value], index) => ( +
+
{key}
- +
- )} */} + ))}
); diff --git a/setup_nova.sh b/setup_nova.sh index 3a84bcb80..5dae3ca8e 100755 --- a/setup_nova.sh +++ b/setup_nova.sh @@ -1,6 +1,6 @@ #!/bin/bash SDK_DIR="iota-sdk" -TARGET_COMMIT="8f0ff5e1e899a0d960ddfea09237739a88c3bcf1" +TARGET_COMMIT="fc9f0f56bb5cfc146993e53aa9656ded220734e1" if [ ! -d "$SDK_DIR" ]; then git clone -b 2.0 git@github.com:iotaledger/iota-sdk.git From 3450cbadf02fb6f5026410e720ebe0cd2a49da79 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Thu, 22 Feb 2024 09:47:44 +0100 Subject: [PATCH 15/31] feat: add nft section --- .../src/app/components/nova/FeaturesView.tsx | 9 +- .../section/AddressPageTabbedSections.tsx | 21 ++ .../address/section/native-tokens/Asset.tsx | 2 +- .../address/section/nft/ImagePlaceholder.scss | 51 +++++ .../address/section/nft/ImagePlaceholder.tsx | 26 +++ .../nova/address/section/nft/Nft.scss | 38 ++++ .../nova/address/section/nft/Nft.tsx | 74 +++++++ .../section/nft/NftMetadataSection.scss | 57 ++++++ .../section/nft/NftMetadataSection.tsx | 181 ++++++++++++++++++ .../address/section/nft/NftMetadataUtils.tsx | 45 +++++ .../nova/address/section/nft/NftSection.scss | 8 + .../nova/address/section/nft/NftSection.tsx | 123 ++++++++++++ client/src/models/api/nova/nft/INftBase.ts | 16 ++ 13 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 client/src/app/components/nova/address/section/nft/ImagePlaceholder.scss create mode 100644 client/src/app/components/nova/address/section/nft/ImagePlaceholder.tsx create mode 100644 client/src/app/components/nova/address/section/nft/Nft.scss create mode 100644 client/src/app/components/nova/address/section/nft/Nft.tsx create mode 100644 client/src/app/components/nova/address/section/nft/NftMetadataSection.scss create mode 100644 client/src/app/components/nova/address/section/nft/NftMetadataSection.tsx create mode 100644 client/src/app/components/nova/address/section/nft/NftMetadataUtils.tsx create mode 100644 client/src/app/components/nova/address/section/nft/NftSection.scss create mode 100644 client/src/app/components/nova/address/section/nft/NftSection.tsx create mode 100644 client/src/models/api/nova/nft/INftBase.ts diff --git a/client/src/app/components/nova/FeaturesView.tsx b/client/src/app/components/nova/FeaturesView.tsx index 5754ab37d..f38d219f9 100644 --- a/client/src/app/components/nova/FeaturesView.tsx +++ b/client/src/app/components/nova/FeaturesView.tsx @@ -50,7 +50,14 @@ const FeatureView: React.FC = ({ feature, isImmutable, isPreEx {feature.type === FeatureType.Issuer && } {feature.type === FeatureType.Metadata && (
- + {Object.entries((feature as MetadataFeature).entries).map(([key, value], index) => ( +
+
{key}
+
+ +
+
+ ))}
)} {feature.type === FeatureType.StateMetadata &&
State metadata unimplemented
} diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index e548927c5..5c03d216e 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -20,6 +20,7 @@ import AnchorStateSection from "./anchor/AnchorStateSection"; enum DEFAULT_TABS { AssocOutputs = "Outputs", NativeTokens = "Native Tokens", + Nfts = "NFTs", } enum ACCOUNT_TABS { @@ -31,6 +32,10 @@ enum ANCHOR_TABS { State = "State", } +// enum NFT_TABS { +// NftMetadata = "Metadata", +// } + const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: number) => ({ [DEFAULT_TABS.AssocOutputs]: { disabled: associatedOutputCount === 0, @@ -44,6 +49,13 @@ const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: num counter: tokensCount, infoContent: nativeTokensMessage, }, + // [DEFAULT_TABS.Nfts]: { + // disabled: nftCount === 0, + // hidden: nftCount === 0, + // counter: nftCount, + // isLoading: isNftOutputsLoading, + // infoContent: addressNftsMessage, + // }, }); const buildAccountAddressTabsOptions = ( @@ -75,6 +87,15 @@ const buildAnchorAddressTabsOptions = (isAnchorStateTabDisabled: boolean, isAnch }, }); +// const buildNftAddressTabsOptions = (isNftMetadataTabDisabled: boolean, isNftDetailsLoading: boolean) => ({ +// [NFT_TABS.NftMetadata]: { +// disabled: isNftMetadataTabDisabled, +// hidden: isNftMetadataTabDisabled, +// isLoading: isNftDetailsLoading, +// infoContent: nftMetadataMessage, +// }, +// }); + interface IAddressPageTabbedSectionsProps { readonly addressState: | IEd25519AddressState diff --git a/client/src/app/components/nova/address/section/native-tokens/Asset.tsx b/client/src/app/components/nova/address/section/native-tokens/Asset.tsx index 426614915..b2ecd50dd 100644 --- a/client/src/app/components/nova/address/section/native-tokens/Asset.tsx +++ b/client/src/app/components/nova/address/section/native-tokens/Asset.tsx @@ -36,7 +36,7 @@ const Asset: React.FC = ({ tableFormat, token }) => { const validator = new JsonSchemaValidator(); try { - const tokenInfo = JSON.parse(hexToUtf8(metadata.data)) as Irc30Metadata; + const tokenInfo = JSON.parse(hexToUtf8(metadata.entries[Object.keys(metadata.entries)[0]])) as Irc30Metadata; const result = validator.validate(tokenInfo, tokenSchemeIRC30); if (result.valid) { diff --git a/client/src/app/components/nova/address/section/nft/ImagePlaceholder.scss b/client/src/app/components/nova/address/section/nft/ImagePlaceholder.scss new file mode 100644 index 000000000..6efb463cd --- /dev/null +++ b/client/src/app/components/nova/address/section/nft/ImagePlaceholder.scss @@ -0,0 +1,51 @@ +@import "../../../../../../scss/mixins.scss"; +@import "../../../../../../scss/fonts.scss"; +@import "../../../../../../scss/media-queries"; + +.nft-image-placeholder { + display: flex; + align-items: center; + justify-content: center; + min-width: 398px; + height: 398px; + border-radius: 1em; + + @include desktop-down { + min-width: 300px; + height: 290px; + } + + @include tablet-down { + align-self: center; + width: 100%; + max-width: 300px; + height: 280px; + } + + &__content { + display: flex; + + @include font-size(28px); + padding: 10px; + letter-spacing: 0.16em; + font-family: $metropolis-light; + line-height: 1.5em; + text-align: center; + color: var(--body-color); + } + + &.compact { + min-width: 200px; + height: 180px; + margin-bottom: 4px; + + .nft-image-placeholder__content { + @include font-size(16px); + letter-spacing: 0.1em; + font-family: $metropolis-light; + line-height: 1.5em; + text-align: center; + color: var(--body-color); + } + } +} diff --git a/client/src/app/components/nova/address/section/nft/ImagePlaceholder.tsx b/client/src/app/components/nova/address/section/nft/ImagePlaceholder.tsx new file mode 100644 index 000000000..e2e2ffc51 --- /dev/null +++ b/client/src/app/components/nova/address/section/nft/ImagePlaceholder.tsx @@ -0,0 +1,26 @@ +import classNames from "classnames"; +import React from "react"; +import Spinner from "../../../../Spinner"; +import "./ImagePlaceholder.scss"; + +interface ImagePlaceholderProps { + readonly message: string; + readonly color?: string; + readonly compact?: boolean; + readonly isLoading?: boolean; +} + +export const ImagePlaceholder: React.FC = ({ message, color, compact, isLoading }) => ( +
+
+ {message} + {isLoading && } +
+
+); + +ImagePlaceholder.defaultProps = { + color: undefined, + compact: false, + isLoading: false, +}; diff --git a/client/src/app/components/nova/address/section/nft/Nft.scss b/client/src/app/components/nova/address/section/nft/Nft.scss new file mode 100644 index 000000000..8aeebb7c1 --- /dev/null +++ b/client/src/app/components/nova/address/section/nft/Nft.scss @@ -0,0 +1,38 @@ +@import "../../../../../../scss/fonts"; +@import "../../../../../../scss/mixins"; +@import "../../../../../../scss/variables"; + +.nft-card { + display: flex; + width: 200px; + height: 235px; + flex-direction: column; + font-family: $inter; + margin: 4px 8px; + + &__metadata { + height: 208px; + } + + &__image { + border-radius: 8px; + object-fit: cover; + width: 200px; + height: 180px; + } + + &__name { + @include font-size(14px); + font-weight: 400; + width: 100%; + text-align: center; + color: var(--body-color); + } + + &__id { + @include font-size(14px); + font-family: $ibm-plex-mono; + font-weight: normal; + color: var(--link-color); + } +} diff --git a/client/src/app/components/nova/address/section/nft/Nft.tsx b/client/src/app/components/nova/address/section/nft/Nft.tsx new file mode 100644 index 000000000..d91ea37fc --- /dev/null +++ b/client/src/app/components/nova/address/section/nft/Nft.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { + isSupportedImageFormat, + noMetadataPlaceholder, + nonStandardMetadataPlaceholder, + unregisteredMetadataPlaceholder, + unsupportedImageFormatPlaceholderCompact, + getNftImageContent, + loadingImagePlaceholderCompact, +} from "./NftMetadataUtils"; +import nftSchemeIRC27 from "~assets/schemas/nft-schema-IRC27.json"; +import { useNftMetadataUri } from "~helpers/stardust/hooks/useNftMetadataUri"; +import { useTokenRegistryNftCheck } from "~helpers/stardust/hooks/useTokenRegistryNftCheck"; +import { tryParseMetadata } from "~helpers/stardust/metadataUtils"; +import { INftImmutableMetadata } from "~models/api/stardust/nft/INftImmutableMetadata"; +import "./Nft.scss"; +import { INftBase } from "~/models/api/nova/nft/INftBase"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { Utils } from "@iota/sdk-wasm-nova/web"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; + +export interface NftProps { + /** + * + * NFT + */ + nft: INftBase; +} + +const Nft: React.FC = ({ nft }) => { + const id = nft.nftId; + const standardMetadata = nft.metadata ? tryParseMetadata(nft.metadata.entries[0], nftSchemeIRC27) : null; + const { name: network, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const nftAddress = Utils.hexToBech32(id, bech32Hrp); + const [isWhitelisted] = useTokenRegistryNftCheck(nft.issuerId, id); + const [name, setName] = useState(); + const [uri, isNftUriLoading] = useNftMetadataUri(standardMetadata?.uri); + + useEffect(() => { + setName(null); + if (standardMetadata) { + setName(standardMetadata.name); + } + }, [standardMetadata]); + + const unsupportedFormatOrLoading = isNftUriLoading ? loadingImagePlaceholderCompact : unsupportedImageFormatPlaceholderCompact; + + const standardMetadataImageContent = isWhitelisted + ? standardMetadata && uri && isSupportedImageFormat(standardMetadata.type) + ? getNftImageContent(standardMetadata.type, uri, "nft-card__image") + : unsupportedFormatOrLoading + : unregisteredMetadataPlaceholder; + + const nftImageContent = nft.metadata + ? standardMetadata + ? standardMetadataImageContent + : nonStandardMetadataPlaceholder + : noMetadataPlaceholder; + + return ( +
+
+ {nftImageContent} + + + +
+ {name && isWhitelisted && {name}} +
+ ); +}; + +export default Nft; diff --git a/client/src/app/components/nova/address/section/nft/NftMetadataSection.scss b/client/src/app/components/nova/address/section/nft/NftMetadataSection.scss new file mode 100644 index 000000000..bd9679ef5 --- /dev/null +++ b/client/src/app/components/nova/address/section/nft/NftMetadataSection.scss @@ -0,0 +1,57 @@ +@import "../../../../../../scss/media-queries"; + +.nft-metadata { + display: flex; + flex-direction: row; + + @include tablet-down { + flex-direction: column; + } + + .nft-metadata__image { + width: 398px; + height: fit-content; + object-fit: contain; + margin-top: 16px; + + @include desktop-down { + width: 300px; + height: 290px; + } + + @include tablet-down { + align-self: center; + width: 100%; + max-width: 398px; + height: auto; + } + } + + .nft-metadata__info { + margin-left: 40px; + @include tablet-down { + margin-left: 0; + margin-top: 20px; + } + + &__description { + word-break: break-word !important; + text-align: justify; + } + + li { + align-items: center; + justify-content: space-between; + span.label { + margin-right: 8px; + margin-bottom: 0; + } + } + } + + .value { + font-size: 12px !important; + line-height: 18px; + font-weight: 500; + } +} diff --git a/client/src/app/components/nova/address/section/nft/NftMetadataSection.tsx b/client/src/app/components/nova/address/section/nft/NftMetadataSection.tsx new file mode 100644 index 000000000..efc34f0dd --- /dev/null +++ b/client/src/app/components/nova/address/section/nft/NftMetadataSection.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { + getNftImageContent, + isSupportedImageFormat, + loadingImagePlaceholder, + MESSAGE_NFT_SCHEMA_STANDARD, + unsupportedImageFormatPlaceholder, +} from "./NftMetadataUtils"; +import nftSchemeIRC27 from "~assets/schemas/nft-schema-IRC27.json"; +import { useNftMetadataUri } from "~helpers/stardust/hooks/useNftMetadataUri"; +import { useTokenRegistryNftCheck } from "~helpers/stardust/hooks/useTokenRegistryNftCheck"; +import { tryParseMetadata } from "~helpers/stardust/metadataUtils"; +import { INftBase } from "~models/api/stardust/nft/INftBase"; +import { INftImmutableMetadata } from "~models/api/stardust/nft/INftImmutableMetadata"; +import DataToggle from "../../../../DataToggle"; +import JsonViewer from "../../../../JsonViewer"; +import TruncatedId from "../../../TruncatedId"; +import "./NftMetadataSection.scss"; + +interface NftMetadataSectionProps { + /** + * The network in context. + */ + readonly network: string; + + /** + * The nft. + */ + readonly nft: INftBase; + + /** + * Is nft output loading. + */ + readonly isLoading: boolean; +} + +const NftMetadataSection: React.FC = ({ network, nft, isLoading }) => { + const [standardMetadata, setStandardMetadata] = useState(); + const [isWhitelisted, isChecking] = useTokenRegistryNftCheck(nft.issuerId, nft.nftId); + const [uri, isNftUriLoading] = useNftMetadataUri(standardMetadata?.uri); + + useEffect(() => { + if (nft.metadata) { + setStandardMetadata(tryParseMetadata(nft.metadata, nftSchemeIRC27)); + } + }, [nft.metadata]); + + const unsupportedFormatOrLoading = isNftUriLoading ? loadingImagePlaceholder : unsupportedImageFormatPlaceholder; + + const whitelistedNft = + standardMetadata && isWhitelisted ? ( +
+
+ {uri && isSupportedImageFormat(standardMetadata?.type) + ? getNftImageContent(standardMetadata.type, uri, "nft-metadata__image") + : unsupportedFormatOrLoading} +
+
    +
  • + Name: + {standardMetadata.name} +
  • +
  • + Token Standard: + {standardMetadata.standard} +
  • +
  • + Version: + {standardMetadata.version} +
  • +
  • + Type: + {standardMetadata.type} +
  • + {standardMetadata.collectionName && ( +
  • + Collection Name: + {standardMetadata.collectionName} +
  • + )} + {nft.issuerId && ( +
  • + Issuer Id: + + + +
  • + )} + {standardMetadata.issuerName && ( +
  • + Issuer Name: + {standardMetadata.issuerName} +
  • + )} +
+ {standardMetadata.royalties && ( + +

Royalties

+
+
+ +
+
+
+ )} + {standardMetadata.attributes && ( + +

Attributes

+
+
+ +
+
+
+ )} + {standardMetadata.description && ( + +

Description

+ {standardMetadata.description} +
+ )} +
+
+
+ ) : null; + + const notWhitelistedIRC27 = + standardMetadata && !isWhitelisted ? ( +
+
+

+ {MESSAGE_NFT_SCHEMA_STANDARD}  + + Token Registry. + +

+ +
+
+ ) : null; + + const notIRC27Metadata = ( +
+
+ +
+
+ ); + + const noMetadata = ( +
+
+

There is no metadata for this Nft.

+
+
+ ); + + if (isLoading) { + return null; + } + + if (nft.metadata && !isChecking) { + if (whitelistedNft) { + return whitelistedNft; + } + if (notWhitelistedIRC27) { + return notWhitelistedIRC27; + } + if (notIRC27Metadata) { + return notIRC27Metadata; + } + } + return noMetadata; +}; + +export default NftMetadataSection; diff --git a/client/src/app/components/nova/address/section/nft/NftMetadataUtils.tsx b/client/src/app/components/nova/address/section/nft/NftMetadataUtils.tsx new file mode 100644 index 000000000..0412103cf --- /dev/null +++ b/client/src/app/components/nova/address/section/nft/NftMetadataUtils.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { ImagePlaceholder } from "./ImagePlaceholder"; + +export const MESSAGE_NFT_SCHEMA_STANDARD = "The metadata conforms to the IRC27 standard schema! Please consider submitting an entry to the"; + +export const noMetadataPlaceholder = ; +export const nonStandardMetadataPlaceholder = ; +export const unsupportedImageFormatPlaceholderCompact = ; +export const unsupportedImageFormatPlaceholder = ; +export const unregisteredMetadataPlaceholder = ; +export const loadingImagePlaceholderCompact = ; +export const loadingImagePlaceholder = ; + +/** + * Supported image MIME formats. + */ +const SUPPORTED_IMAGE_FORMATS = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4"]); + +/** + * Validate NFT image MIME type. + * @param nftType The NFT image MIME type. + * @returns A bool. + */ +export function isSupportedImageFormat(nftType: string | undefined): boolean { + if (nftType === undefined) { + return false; + } + + return SUPPORTED_IMAGE_FORMATS.has(nftType); +} + +/** + * Builds the NFT image element depending on content type. + * @param contentType The NFT image MIME type. + * @param uri The NFT image URI. + * @param className The class to use. + * @returns JSX. + */ +export function getNftImageContent(contentType: string, uri: string, className: string): JSX.Element { + return contentType === "video/mp4" ? ( +
) : null; @@ -147,7 +151,7 @@ const NftMetadataSection: React.FC = ({ network, nft, i const notIRC27Metadata = (
- +
); @@ -160,11 +164,7 @@ const NftMetadataSection: React.FC = ({ network, nft, i
); - if (isLoading) { - return null; - } - - if (nft.metadata && !isChecking) { + if (metadata && !isChecking) { if (whitelistedNft) { return whitelistedNft; } diff --git a/client/src/app/components/nova/address/section/nft/NftSection.tsx b/client/src/app/components/nova/address/section/nft/NftSection.tsx index a18b56edf..b3448c8e0 100644 --- a/client/src/app/components/nova/address/section/nft/NftSection.tsx +++ b/client/src/app/components/nova/address/section/nft/NftSection.tsx @@ -1,112 +1,38 @@ -import { - AddressType, - AccountAddress, - Ed25519Address, - FeatureType, - IssuerFeature, - MetadataFeature, - NftAddress, - NftOutput, - OutputResponse, - OutputType, - Utils, -} from "@iota/sdk-wasm-nova/web"; +import { NftOutput, OutputResponse } from "@iota/sdk-wasm-nova/web"; import React, { useEffect, useState } from "react"; import Nft from "./Nft"; import { useIsMounted } from "~helpers/hooks/useIsMounted"; -import { INftBase } from "~models/api/nova/nft/INftBase"; import Pagination from "~/app/components/Pagination"; interface NftSectionProps { - readonly network: string; - readonly bech32Address?: string; readonly outputs: OutputResponse[] | null; - readonly setNftCount?: (count: number) => void; } const PAGE_SIZE = 10; -const NftSection: React.FC = ({ network, bech32Address, outputs, setNftCount }) => { +const NftSection: React.FC = ({ outputs }) => { const isMounted = useIsMounted(); - const [nfts, setNfts] = useState([]); - const [page, setPage] = useState([]); + const [page, setPage] = useState([]); const [pageNumber, setPageNumber] = useState(1); + // On page change handler useEffect(() => { - const theNfts: INftBase[] = []; - if (setNftCount) { - setNftCount(0); - } - if (outputs) { - for (const outputResponse of outputs) { - if (outputResponse && !outputResponse.metadata.spent && outputResponse.output.type === OutputType.Nft) { - const nftOutput = outputResponse.output as NftOutput; - const nftId = Utils.computeNftId(outputResponse.metadata.outputId); - - const metadataFeature = nftOutput.immutableFeatures?.find( - (feature) => feature.type === FeatureType.Metadata, - ) as MetadataFeature; - - const issuerFeature = nftOutput.immutableFeatures?.find( - (feature) => feature.type === FeatureType.Issuer, - ) as IssuerFeature; - - let issuerId = null; - if (issuerFeature) { - switch (issuerFeature.address.type) { - case AddressType.Ed25519: { - issuerId = (issuerFeature.address as Ed25519Address).pubKeyHash; - break; - } - case AddressType.Account: { - issuerId = (issuerFeature.address as AccountAddress).accountId; - break; - } - case AddressType.Nft: { - issuerId = (issuerFeature.address as NftAddress).nftId; - break; - } - default: { - break; - } - } - } - - theNfts.push({ - nftId, - issuerId, - metadata: metadataFeature ?? undefined, - }); - } + const from = (pageNumber - 1) * PAGE_SIZE; + const to = from + PAGE_SIZE; + if (isMounted) { + setPage(outputs.slice(from, to)); } } + }, [outputs, pageNumber]); - if (isMounted) { - setNfts(theNfts); - - if (setNftCount) { - setNftCount(theNfts.length); - } - } - }, [outputs, network, bech32Address]); - - // On page change handler - useEffect(() => { - const from = (pageNumber - 1) * PAGE_SIZE; - const to = from + PAGE_SIZE; - if (isMounted) { - setPage(nfts?.slice(from, to)); - } - }, [nfts, pageNumber]); - - return nfts.length > 0 ? ( + return outputs && outputs.length > 0 ? (
-
{page?.map((nft, idx) => )}
+
{page?.map((output, idx) => )}
setPageNumber(newPage)} @@ -116,7 +42,6 @@ const NftSection: React.FC = ({ network, bech32Address, outputs }; NftSection.defaultProps = { - bech32Address: undefined, setNftCount: undefined, }; diff --git a/client/src/helpers/nova/addressHelper.ts b/client/src/helpers/nova/addressHelper.ts index c1d21b217..facd8ca10 100644 --- a/client/src/helpers/nova/addressHelper.ts +++ b/client/src/helpers/nova/addressHelper.ts @@ -118,6 +118,8 @@ export class AddressHelper { return "NFT"; } else if (addressType === AddressType.Anchor) { return "Anchor"; + } else if (addressType === AddressType.ImplicitAccountCreation) { + return "Implicit"; } } } diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index 09c5ec62d..2a1438b63 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -17,6 +17,7 @@ import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; import { useAccountCongestion } from "./useAccountCongestion"; +import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; @@ -25,11 +26,13 @@ export interface IAccountAddressState { availableBalance: number | null; blockIssuerFeature: BlockIssuerFeature | null; addressBasicOutputs: OutputResponse[] | null; + addressNftOutputs: OutputResponse[] | null; foundries: string[] | null; congestion: CongestionResponse | null; isAccountDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; isBasicOutputsLoading: boolean; + isNftOutputsLoading: boolean; isFoundriesLoading: boolean; isCongestionLoading: boolean; } @@ -41,11 +44,13 @@ const initialState = { availableBalance: null, blockIssuerFeature: null, addressBasicOutputs: null, + addressNftOutputs: null, foundries: null, congestion: null, isAccountDetailsLoading: true, isAssociatedOutputsLoading: false, isBasicOutputsLoading: false, + isNftOutputsLoading: false, isFoundriesLoading: false, isCongestionLoading: false, }; @@ -69,6 +74,7 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres const { accountOutput, isLoading: isAccountDetailsLoading } = useAccountDetails(network, address.accountId); const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, accountOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); const [foundries, isFoundriesLoading] = useAccountControlledFoundries(network, state.addressDetails); const { congestion, isLoading: isCongestionLoading } = useAccountCongestion(network, state.addressDetails?.hex ?? null); @@ -93,7 +99,9 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres foundries, congestion, addressBasicOutputs, + addressNftOutputs, isBasicOutputsLoading, + isNftOutputsLoading, isFoundriesLoading, isCongestionLoading, }; @@ -116,9 +124,11 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres totalBalance, availableBalance, addressBasicOutputs, + addressNftOutputs, congestion, isAccountDetailsLoading, isBasicOutputsLoading, + isNftOutputsLoading, isCongestionLoading, ]); diff --git a/client/src/helpers/nova/hooks/useAddressNftOutputs.ts b/client/src/helpers/nova/hooks/useAddressNftOutputs.ts new file mode 100644 index 000000000..b9ee4cd0d --- /dev/null +++ b/client/src/helpers/nova/hooks/useAddressNftOutputs.ts @@ -0,0 +1,43 @@ +import { OutputResponse } 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"; + +/** + * Fetch Address Nft UTXOs + * @param network The Network in context + * @param addressBech32 The address in bech32 format + * @returns The output responses and loading bool. + */ +export function useAddressNftOutputs(network: string, addressBech32: string | null): [OutputResponse[] | null, boolean] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [outputs, setOutputs] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + setOutputs(null); + if (addressBech32) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .nftOutputsDetails({ network, address: addressBech32 }) + .then((response) => { + if (!response?.error && response.outputs && isMounted) { + setOutputs(response.outputs); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, addressBech32]); + + return [outputs, isLoading]; +} diff --git a/client/src/helpers/nova/hooks/useAnchorAddressState.ts b/client/src/helpers/nova/hooks/useAnchorAddressState.ts index a688fd186..352dc1223 100644 --- a/client/src/helpers/nova/hooks/useAnchorAddressState.ts +++ b/client/src/helpers/nova/hooks/useAnchorAddressState.ts @@ -8,6 +8,7 @@ import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; +import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; export interface IAnchorAddressState { addressDetails: IAddressDetails | null; @@ -15,7 +16,9 @@ export interface IAnchorAddressState { availableBalance: number | null; totalBalance: number | null; addressBasicOutputs: OutputResponse[] | null; + addressNftOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; + isNftOutputsLoading: boolean; isAnchorDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; } @@ -26,7 +29,9 @@ const initialState = { totalBalance: null, availableBalance: null, addressBasicOutputs: null, + addressNftOutputs: null, isBasicOutputsLoading: false, + isNftOutputsLoading: false, isAnchorDetailsLoading: true, isAssociatedOutputsLoading: false, }; @@ -50,6 +55,7 @@ export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressSt const { anchorOutput, isLoading: isAnchorDetailsLoading } = useAnchorDetails(network, address.anchorId); const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, anchorOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -69,10 +75,21 @@ export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressSt totalBalance, availableBalance, addressBasicOutputs, + addressNftOutputs, isBasicOutputsLoading, + isNftOutputsLoading, isAnchorDetailsLoading, }); - }, [anchorOutput, totalBalance, availableBalance, addressBasicOutputs, isBasicOutputsLoading, isAnchorDetailsLoading]); + }, [ + anchorOutput, + totalBalance, + availableBalance, + addressBasicOutputs, + addressNftOutputs, + isBasicOutputsLoading, + isNftOutputsLoading, + isAnchorDetailsLoading, + ]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useEd25519AddressState.ts b/client/src/helpers/nova/hooks/useEd25519AddressState.ts index cc2866a50..0d9e79066 100644 --- a/client/src/helpers/nova/hooks/useEd25519AddressState.ts +++ b/client/src/helpers/nova/hooks/useEd25519AddressState.ts @@ -6,13 +6,16 @@ import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; +import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; export interface IEd25519AddressState { addressDetails: IAddressDetails | null; totalBalance: number | null; availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; + addressNftOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; + isNftOutputsLoading: boolean; isAssociatedOutputsLoading: boolean; } @@ -21,7 +24,9 @@ const initialState = { totalBalance: null, availableBalance: null, addressBasicOutputs: null, + addressNftOutputs: null, isBasicOutputsLoading: false, + isNftOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -42,6 +47,7 @@ export const useEd25519AddressState = (address: Ed25519Address): [IEd25519Addres const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, null); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -59,9 +65,11 @@ export const useEd25519AddressState = (address: Ed25519Address): [IEd25519Addres totalBalance, availableBalance, addressBasicOutputs, + addressNftOutputs, isBasicOutputsLoading, + isNftOutputsLoading, }); - }, [totalBalance, availableBalance, addressBasicOutputs, isBasicOutputsLoading]); + }, [totalBalance, availableBalance, addressBasicOutputs, addressNftOutputs, isBasicOutputsLoading, isNftOutputsLoading]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts index d0277b00e..ea28ac024 100644 --- a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts +++ b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts @@ -7,13 +7,16 @@ import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; +import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; export interface IImplicitAccountCreationAddressState { addressDetails: IAddressDetails | null; totalBalance: number | null; availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; + addressNftOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; + isNftOutputsLoading: boolean; isAssociatedOutputsLoading: boolean; } @@ -22,7 +25,9 @@ const initialState = { totalBalance: null, availableBalance: null, addressBasicOutputs: null, + addressNftOutputs: null, isBasicOutputsLoading: false, + isNftOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -46,6 +51,7 @@ export const useImplicitAccountCreationAddressState = ( const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, null); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -64,9 +70,11 @@ export const useImplicitAccountCreationAddressState = ( totalBalance, availableBalance, addressBasicOutputs, + addressNftOutputs, isBasicOutputsLoading, + isNftOutputsLoading, }); - }, [totalBalance, availableBalance, addressBasicOutputs, isBasicOutputsLoading]); + }, [totalBalance, availableBalance, addressBasicOutputs, addressNftOutputs, isBasicOutputsLoading, isBasicOutputsLoading]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useNftAddressState.ts b/client/src/helpers/nova/hooks/useNftAddressState.ts index ebc43c8dd..0852ecdd0 100644 --- a/client/src/helpers/nova/hooks/useNftAddressState.ts +++ b/client/src/helpers/nova/hooks/useNftAddressState.ts @@ -8,6 +8,7 @@ import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; +import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; export interface INftAddressState { addressDetails: IAddressDetails | null; @@ -15,7 +16,9 @@ export interface INftAddressState { totalBalance: number | null; availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; + addressNftOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; + isNftOutputsLoading: boolean; isNftDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; } @@ -27,7 +30,9 @@ const initialState = { totalBalance: null, availableBalance: null, addressBasicOutputs: null, + addressNftOutputs: null, isBasicOutputsLoading: false, + isNftOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -50,6 +55,7 @@ export const useNftAddressState = (address: NftAddress): [INftAddressState, Reac const { nftOutput, isLoading: isNftDetailsLoading } = useNftDetails(network, address.nftId); const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, nftOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -70,9 +76,20 @@ export const useNftAddressState = (address: NftAddress): [INftAddressState, Reac availableBalance, isNftDetailsLoading, addressBasicOutputs, + addressNftOutputs, isBasicOutputsLoading, + isNftOutputsLoading, }); - }, [nftOutput, totalBalance, availableBalance, isNftDetailsLoading, addressBasicOutputs, isBasicOutputsLoading]); + }, [ + nftOutput, + totalBalance, + availableBalance, + isNftDetailsLoading, + addressBasicOutputs, + addressNftOutputs, + isBasicOutputsLoading, + isNftOutputsLoading, + ]); return [state, setState]; }; diff --git a/client/src/helpers/nova/transactionsHelper.ts b/client/src/helpers/nova/transactionsHelper.ts index a173f3046..8ba2261ca 100644 --- a/client/src/helpers/nova/transactionsHelper.ts +++ b/client/src/helpers/nova/transactionsHelper.ts @@ -1,13 +1,23 @@ import { + AccountAddress, + AddressType, AddressUnlockCondition, + AnchorAddress, BasicBlockBody, Block, BlockBodyType, CommonOutput, DelegationOutput, + Ed25519Address, + FeatureType, GovernorAddressUnlockCondition, ImmutableAccountAddressUnlockCondition, + ImplicitAccountCreationAddress, InputType, + IssuerFeature, + MetadataFeature, + NftAddress, + NftOutput, OutputType, PayloadType, SignatureUnlock, @@ -25,6 +35,7 @@ import { IOutput } from "~/models/api/nova/IOutput"; import { NovaApiClient } from "~/services/nova/novaApiClient"; import { Converter } from "../stardust/convertUtils"; import { AddressHelper } from "./addressHelper"; +import { plainToInstance } from "class-transformer"; interface TransactionInputsAndOutputsResponse { inputs: IInput[]; @@ -200,6 +211,64 @@ export class TransactionsHelper { }); } + /** + * Retrieves the MetadataFeature from the given NftOutput. + * @param output The NftOutput to retrieve the MetadataFeature from. + * @returns The MetadataFeature if found, otherwise undefined. + */ + public static getNftMetadataFeature(output: NftOutput): MetadataFeature | null { + const metadataFeature = output?.immutableFeatures?.find((feature) => feature.type === FeatureType.Metadata) as MetadataFeature; + + return metadataFeature ?? null; + } + + /** + * Get the issuer ID from the issuer feature. + * @param output The nft output to get the issuer ID from. + * @returns The issuer ID. + */ + public static getNftIssuerId(output: NftOutput): string | null { + const issuerFeature = output?.immutableFeatures?.find((feature) => feature.type === FeatureType.Issuer) as IssuerFeature; + + let issuerId = null; + if (issuerFeature) { + switch (issuerFeature.address.type) { + case AddressType.Ed25519: { + const ed25519Address = issuerFeature.address as Ed25519Address; + issuerId = ed25519Address.pubKeyHash; + break; + } + case AddressType.Account: { + const accountAddress = issuerFeature.address as AccountAddress; + issuerId = accountAddress.accountId; + break; + } + case AddressType.Nft: { + const nftAddress = issuerFeature.address as NftAddress; + issuerId = nftAddress.nftId; + break; + } + case AddressType.Anchor: { + const anchorAddress = issuerFeature.address as AnchorAddress; + issuerId = anchorAddress.anchorId; + break; + } + case AddressType.ImplicitAccountCreation: { + const implicitAddress = issuerFeature.address as ImplicitAccountCreationAddress; + const implicitAccountCreationAddress = plainToInstance(ImplicitAccountCreationAddress, implicitAddress); + const innerAddress = implicitAccountCreationAddress.address(); + issuerId = (innerAddress as Ed25519Address).pubKeyHash; + break; + } + default: { + break; + } + } + } + + return issuerId; + } + private static bechAddressFromAddressUnlockCondition( unlockConditions: UnlockCondition[], _bechHrp: string, diff --git a/client/src/models/api/nova/nft/INftBase.ts b/client/src/models/api/nova/nft/INftBase.ts deleted file mode 100644 index a90fab035..000000000 --- a/client/src/models/api/nova/nft/INftBase.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MetadataFeature } from "@iota/sdk-wasm-nova/web"; - -export interface INftBase { - /** - * The hex NftId of this NFT - */ - nftId: string; - /** - * The hex id of the immutable issuer. - */ - issuerId: string | null; - /** - * NFT Metadata - */ - metadata: MetadataFeature | null; -} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 859be2fcc..3fcd33056 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -135,6 +135,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/address/outputs/basic/${request.network}/${request.address}`, "get"); } + /** + * Get the nft outputs details of an address. + * @param request The Address outputs request. + * @returns The Address outputs response + */ + public async nftOutputsDetails(request: IAddressDetailsRequest): Promise { + return this.callApi(`nova/address/outputs/nft/${request.network}/${request.address}`, "get"); + } + /** * Get the associated outputs. * @param request The request to send. From 7fc235e4a19bd084ca66c9a1e258f7134c422854 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Thu, 22 Feb 2024 15:20:04 +0100 Subject: [PATCH 17/31] fix: add null checks in BIC section --- .../account/AccountBlockIssuanceSection.tsx | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/client/src/app/components/nova/address/section/account/AccountBlockIssuanceSection.tsx b/client/src/app/components/nova/address/section/account/AccountBlockIssuanceSection.tsx index 9b1557586..7b86445eb 100644 --- a/client/src/app/components/nova/address/section/account/AccountBlockIssuanceSection.tsx +++ b/client/src/app/components/nova/address/section/account/AccountBlockIssuanceSection.tsx @@ -12,32 +12,40 @@ const AccountBlockIssuanceSection: React.FC = return (
-
-
Current Slot
-
{congestion?.slot}
-
-
-
Block Issuance Credit
-
{congestion?.blockIssuanceCredits.toString()}
-
-
-
Referenced Mana Cost
-
{congestion?.referenceManaCost.toString()}
-
-
-
Expiry Slot
-
{blockIssuerFeature?.expirySlot}
-
-
- {blockIssuerFeature?.blockIssuerKeys.map((key) => ( - - Public Key: -
- -
-
- ))} -
+ {congestion && ( + <> +
+
Current Slot
+
{congestion.slot}
+
+
+
Block Issuance Credit
+
{congestion.blockIssuanceCredits.toString()}
+
+
+
Referenced Mana Cost
+
{congestion.referenceManaCost.toString()}
+
+ + )} + {blockIssuerFeature && ( + <> +
+
Expiry Slot
+
{blockIssuerFeature.expirySlot}
+
+
+ {blockIssuerFeature.blockIssuerKeys.map((key) => ( + + Public Key: +
+ +
+
+ ))} +
+ + )}
); From 11be5ecc6866812a33e6c056dca03f74642f10a0 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Fri, 23 Feb 2024 12:42:22 +0100 Subject: [PATCH 18/31] fix: remove leftover nft metadata property in AddressPageTabbedSection --- .../nova/address/section/AddressPageTabbedSections.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 61dab4cbf..59910d959 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -20,6 +20,7 @@ import AccountBlockIssuanceSection from "./account/AccountBlockIssuanceSection"; import AnchorStateSection from "./anchor/AnchorStateSection"; import NftSection from "~/app/components/nova/address/section/nft/NftSection"; import NftMetadataSection from "~/app/components/nova/address/section/nft/NftMetadataSection"; +import { TransactionsHelper } from "~/helpers/nova/transactionsHelper"; enum DEFAULT_TABS { AssocOutputs = "Outputs", @@ -193,10 +194,11 @@ export const AddressPageTabbedSections: React.FC Date: Fri, 23 Feb 2024 14:56:55 +0100 Subject: [PATCH 19/31] feat: add validators tab to account address page --- .../nova/IAccountValidatorDetailsRequest.ts | 11 ++++ .../nova/IAccountValidatorDetailsResponse.ts | 11 ++++ api/src/routes.ts | 6 ++ api/src/routes/nova/account/validator/get.ts | 30 ++++++++++ api/src/services/nova/novaApiService.ts | 20 +++++++ .../section/AddressPageTabbedSections.tsx | 17 ++++++ .../account/AccountValidatorSection.tsx | 55 +++++++++++++++++++ .../assets/modals/nova/account/validator.json | 11 ++++ .../nova/hooks/useAccountAddressState.ts | 14 +++++ .../nova/hooks/useAccountValidatorDetails.ts | 48 ++++++++++++++++ .../nova/IAccountValidatorDetailsRequest.ts | 11 ++++ .../nova/IAccountValidatorDetailsResponse.ts | 9 +++ client/src/services/nova/novaApiClient.ts | 11 ++++ 13 files changed, 254 insertions(+) create mode 100644 api/src/models/api/nova/IAccountValidatorDetailsRequest.ts create mode 100644 api/src/models/api/nova/IAccountValidatorDetailsResponse.ts create mode 100644 api/src/routes/nova/account/validator/get.ts create mode 100644 client/src/app/components/nova/address/section/account/AccountValidatorSection.tsx create mode 100644 client/src/assets/modals/nova/account/validator.json create mode 100644 client/src/helpers/nova/hooks/useAccountValidatorDetails.ts create mode 100644 client/src/models/api/nova/IAccountValidatorDetailsRequest.ts create mode 100644 client/src/models/api/nova/IAccountValidatorDetailsResponse.ts diff --git a/api/src/models/api/nova/IAccountValidatorDetailsRequest.ts b/api/src/models/api/nova/IAccountValidatorDetailsRequest.ts new file mode 100644 index 000000000..0e4b099da --- /dev/null +++ b/api/src/models/api/nova/IAccountValidatorDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface IAccountValidatorDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the validator details for. + */ + accountId: string; +} diff --git a/api/src/models/api/nova/IAccountValidatorDetailsResponse.ts b/api/src/models/api/nova/IAccountValidatorDetailsResponse.ts new file mode 100644 index 000000000..13b205e30 --- /dev/null +++ b/api/src/models/api/nova/IAccountValidatorDetailsResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { ValidatorResponse } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface IAccountValidatorDetailsResponse extends IResponse { + /** + * The account validator response. + */ + validatorDetails?: ValidatorResponse; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index 31759ec7b..78719addd 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -254,6 +254,12 @@ export const routes: IRoute[] = [ folder: "nova/account/congestion", func: "get", }, + { + path: "/nova/account/validator/:network/:accountId", + method: "get", + folder: "nova/account/validator", + func: "get", + }, { 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" }, ]; diff --git a/api/src/routes/nova/account/validator/get.ts b/api/src/routes/nova/account/validator/get.ts new file mode 100644 index 000000000..f4e837c4f --- /dev/null +++ b/api/src/routes/nova/account/validator/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { IAccountValidatorDetailsRequest } from "../../../../models/api/nova/IAccountValidatorDetailsRequest"; +import { IAccountValidatorDetailsResponse } from "../../../../models/api/nova/IAccountValidatorDetailsResponse"; +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"; + +/** + * Get validator details for Account address + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: IAccountValidatorDetailsRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.accountId, "accountId"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.getAccountValidatorDetails(request.accountId); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 1b3fe99a6..3facdb5b8 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -7,6 +7,7 @@ import logger from "../../logger"; import { IFoundriesResponse } from "../../models/api/nova/foundry/IFoundriesResponse"; import { IFoundryResponse } from "../../models/api/nova/foundry/IFoundryResponse"; import { IAccountDetailsResponse } from "../../models/api/nova/IAccountDetailsResponse"; +import { IAccountValidatorDetailsResponse } from "../../models/api/nova/IAccountValidatorDetailsResponse"; import { IAddressDetailsResponse } from "../../models/api/nova/IAddressDetailsResponse"; import { IAnchorDetailsResponse } from "../../models/api/nova/IAnchorDetailsResponse"; import { IBlockDetailsResponse } from "../../models/api/nova/IBlockDetailsResponse"; @@ -296,6 +297,25 @@ export class NovaApiService { } } + /** + * Get validator details for Account + * @param accountId The account id to get the validator details for. + * @returns The Congestion. + */ + public async getAccountValidatorDetails(accountId: string): Promise { + try { + const response = await this.client.getValidator(accountId); + + if (response) { + return { + validatorDetails: response, + }; + } + } catch { + return { message: "Validator details not found" }; + } + } + /** * Get the output mana rewards. * @param outputId The outputId to get the rewards for. diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 59910d959..ca9c5f2ea 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -3,6 +3,7 @@ import associatedOuputsMessage from "~assets/modals/stardust/address/associated- import foundriesMessage from "~assets/modals/stardust/alias/foundries.json"; import stateMessage from "~assets/modals/stardust/alias/state.json"; import bicMessage from "~assets/modals/nova/account/bic.json"; +import validatorMessage from "~assets/modals/nova/account/validator.json"; import nftMetadataMessage from "~assets/modals/stardust/nft/metadata.json"; import addressNftsMessage from "~assets/modals/stardust/address/nfts-in-wallet.json"; import TabbedSection from "../../../hoc/TabbedSection"; @@ -21,6 +22,7 @@ import AnchorStateSection from "./anchor/AnchorStateSection"; import NftSection from "~/app/components/nova/address/section/nft/NftSection"; import NftMetadataSection from "~/app/components/nova/address/section/nft/NftMetadataSection"; import { TransactionsHelper } from "~/helpers/nova/transactionsHelper"; +import AccountValidatorSection from "./account/AccountValidatorSection"; enum DEFAULT_TABS { AssocOutputs = "Outputs", @@ -31,6 +33,7 @@ enum DEFAULT_TABS { enum ACCOUNT_TABS { BlockIssuance = "Block Issuance", Foundries = "Foundries", + Validation = "Validation", } enum ANCHOR_TABS { @@ -74,7 +77,9 @@ const buildAccountAddressTabsOptions = ( isBlockIssuer: boolean, isCongestionLoading: boolean, foundriesCount: number, + hasValidatorDetails: boolean, isAccountFoundriesLoading: boolean, + isValidatorDetailsLoading: boolean, ) => ({ [ACCOUNT_TABS.Foundries]: { disabled: foundriesCount === 0, @@ -88,6 +93,12 @@ const buildAccountAddressTabsOptions = ( isLoading: isCongestionLoading, infoContent: bicMessage, }, + [ACCOUNT_TABS.Validation]: { + disabled: !hasValidatorDetails, + hidden: !hasValidatorDetails, + isLoading: isValidatorDetailsLoading, + infoContent: validatorMessage, + }, }); const buildAnchorAddressTabsOptions = (isAnchorStateTabDisabled: boolean, isAnchorDetailsLoading: boolean) => ({ @@ -151,6 +162,10 @@ export const AddressPageTabbedSections: React.FC, + , ] : null; @@ -186,7 +201,9 @@ export const AddressPageTabbedSections: React.FC = ({ validatorDetails }) => { + if (!validatorDetails) { + return null; + } + + const delegatedStake = BigInt(validatorDetails.poolStake) - BigInt(validatorDetails.validatorStake); + + return ( +
+
+
+
Active
+
{validatorDetails.active ? "Yes" : "No"}
+
+
+
Staking End Epoch
+
{validatorDetails.stakingEndEpoch}
+
+
+
Pool Stake
+
{String(validatorDetails.poolStake)}
+
+
+
Validator Stake
+
{String(validatorDetails.validatorStake)}
+
+
+
Delegated Stake
+
{String(delegatedStake)}
+
+
+
Fixed Cost
+
{Number(validatorDetails?.fixedCost)}
+
+
+
Latest Supported Protocol Version
+
{validatorDetails?.latestSupportedProtocolVersion}
+
+
+
Latest Supported Protocol Hash
+
{validatorDetails?.latestSupportedProtocolHash}
+
+
+
+ ); +}; + +export default AccountValidatorSection; diff --git a/client/src/assets/modals/nova/account/validator.json b/client/src/assets/modals/nova/account/validator.json new file mode 100644 index 000000000..a512acaed --- /dev/null +++ b/client/src/assets/modals/nova/account/validator.json @@ -0,0 +1,11 @@ +{ + "title": "Validator", + "description": "

Validators are special nodes that issue validation blocks.

", + "links": [ + { + "label": "Read more", + "href": "https://wiki.iota.org/learn/protocols/iota2.0/core-concepts/validators", + "isExternal": true + } + ] +} diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index 2a1438b63..692f99af6 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -6,6 +6,7 @@ import { CongestionResponse, FeatureType, OutputResponse, + ValidatorResponse, } from "@iota/sdk-wasm-nova/web"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useAccountDetails } from "./useAccountDetails"; @@ -18,6 +19,7 @@ import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutp import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; import { useAccountCongestion } from "./useAccountCongestion"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; +import { useAccountValidatorDetails } from "./useAccountValidatorDetails"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; @@ -25,6 +27,7 @@ export interface IAccountAddressState { totalBalance: number | null; availableBalance: number | null; blockIssuerFeature: BlockIssuerFeature | null; + validatorDetails: ValidatorResponse | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; foundries: string[] | null; @@ -35,6 +38,7 @@ export interface IAccountAddressState { isNftOutputsLoading: boolean; isFoundriesLoading: boolean; isCongestionLoading: boolean; + isValidatorDetailsLoading: boolean; } const initialState = { @@ -43,6 +47,7 @@ const initialState = { totalBalance: null, availableBalance: null, blockIssuerFeature: null, + validatorDetails: null, addressBasicOutputs: null, addressNftOutputs: null, foundries: null, @@ -53,6 +58,7 @@ const initialState = { isNftOutputsLoading: false, isFoundriesLoading: false, isCongestionLoading: false, + isValidatorDetailsLoading: false, }; /** @@ -77,6 +83,10 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); const [foundries, isFoundriesLoading] = useAccountControlledFoundries(network, state.addressDetails); const { congestion, isLoading: isCongestionLoading } = useAccountCongestion(network, state.addressDetails?.hex ?? null); + const { validatorDetails, isLoading: isValidatorDetailsLoading } = useAccountValidatorDetails( + network, + state.addressDetails?.hex ?? null, + ); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -98,12 +108,14 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres availableBalance, foundries, congestion, + validatorDetails, addressBasicOutputs, addressNftOutputs, isBasicOutputsLoading, isNftOutputsLoading, isFoundriesLoading, isCongestionLoading, + isValidatorDetailsLoading, }; if (accountOutput && !state.blockIssuerFeature) { @@ -126,10 +138,12 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres addressBasicOutputs, addressNftOutputs, congestion, + validatorDetails, isAccountDetailsLoading, isBasicOutputsLoading, isNftOutputsLoading, isCongestionLoading, + isValidatorDetailsLoading, ]); return [state, setState]; diff --git a/client/src/helpers/nova/hooks/useAccountValidatorDetails.ts b/client/src/helpers/nova/hooks/useAccountValidatorDetails.ts new file mode 100644 index 000000000..62c0450df --- /dev/null +++ b/client/src/helpers/nova/hooks/useAccountValidatorDetails.ts @@ -0,0 +1,48 @@ +import { ValidatorResponse } from "@iota/sdk-wasm-nova/web"; +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"; + +/** + * Fetch account validator information + * @param network The Network in context + * @param accountId The account id + * @returns The output response and loading bool. + */ +export function useAccountValidatorDetails( + network: string, + accountId: string | null, +): { validatorDetails: ValidatorResponse | null; isLoading: boolean } { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [validatorDetails, setValidatorDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (accountId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .getAccountValidatorDetails({ + network, + accountId, + }) + .then((response) => { + if (!response?.error && isMounted) { + setValidatorDetails(response.validatorDetails ?? null); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, accountId]); + + return { validatorDetails, isLoading }; +} diff --git a/client/src/models/api/nova/IAccountValidatorDetailsRequest.ts b/client/src/models/api/nova/IAccountValidatorDetailsRequest.ts new file mode 100644 index 000000000..0e4b099da --- /dev/null +++ b/client/src/models/api/nova/IAccountValidatorDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface IAccountValidatorDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the validator details for. + */ + accountId: string; +} diff --git a/client/src/models/api/nova/IAccountValidatorDetailsResponse.ts b/client/src/models/api/nova/IAccountValidatorDetailsResponse.ts new file mode 100644 index 000000000..8a41fba3f --- /dev/null +++ b/client/src/models/api/nova/IAccountValidatorDetailsResponse.ts @@ -0,0 +1,9 @@ +import { ValidatorResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; + +export interface IAccountValidatorDetailsResponse extends IResponse { + /** + * The account validator response. + */ + validatorDetails?: ValidatorResponse; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 3fcd33056..32c4ac302 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -31,6 +31,8 @@ import { IFoundriesResponse } from "~/models/api/nova/foundry/IFoundriesResponse import { IFoundriesRequest } from "~/models/api/nova/foundry/IFoundriesRequest"; import { ICongestionRequest } from "~/models/api/nova/ICongestionRequest"; import { ICongestionResponse } from "~/models/api/nova/ICongestionResponse"; +import { IAccountValidatorDetailsRequest } from "~/models/api/nova/IAccountValidatorDetailsRequest"; +import { IAccountValidatorDetailsResponse } from "~/models/api/nova/IAccountValidatorDetailsResponse"; /** * Class to handle api communications on nova. @@ -166,6 +168,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/account/congestion/${request.network}/${request.accountId}`, "get"); } + /** + * Get the account validator info. + * @param request The request to send. + * @returns The response from the request. + */ + public async getAccountValidatorDetails(request: IAccountValidatorDetailsRequest): Promise { + return this.callApi(`nova/account/validator/${request.network}/${request.accountId}`, "get"); + } + /** * Get the output mana rewards. * @param request The request to send. From bb32fc02fa6cebf969bee55fd7f1a466b20717dc Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Mon, 26 Feb 2024 10:10:01 +0100 Subject: [PATCH 20/31] fix: update sdk commit --- .github/workflows/nova-build-temp.yaml | 2 +- setup_nova.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nova-build-temp.yaml b/.github/workflows/nova-build-temp.yaml index 7b718a706..3d5ef83f6 100644 --- a/.github/workflows/nova-build-temp.yaml +++ b/.github/workflows/nova-build-temp.yaml @@ -6,7 +6,7 @@ on: TARGET_COMMIT: description: "Target Commit Hash for the SDK" required: false - default: "fc9f0f56bb5cfc146993e53aa9656ded220734e1" + default: "56b19d4c12c1bf23bc716b47c36018d37d5b3168" environment: type: choice description: "Select the environment to deploy to" diff --git a/setup_nova.sh b/setup_nova.sh index b69fcd007..d036a032a 100755 --- a/setup_nova.sh +++ b/setup_nova.sh @@ -1,6 +1,6 @@ #!/bin/bash SDK_DIR="iota-sdk" -TARGET_COMMIT="fc9f0f56bb5cfc146993e53aa9656ded220734e1" +TARGET_COMMIT="56b19d4c12c1bf23bc716b47c36018d37d5b3168" if [ ! -d "$SDK_DIR" ]; then git clone -b 2.0 git@github.com:iotaledger/iota-sdk.git From 7f8be4bcfbaeb08c6e9ece0f693f6ffb7077a7c4 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Mon, 26 Feb 2024 14:36:05 +0100 Subject: [PATCH 21/31] Add delegation section --- .github/workflows/nova-build-temp.yaml | 2 +- .../section/AddressPageTabbedSections.tsx | 11 ++++++++++ .../address/section/DelegationSection.tsx | 20 +++++++++++++++++++ client/src/assets/modals/nova/delegation.json | 11 ++++++++++ setup_nova.sh | 2 +- 5 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 client/src/app/components/nova/address/section/DelegationSection.tsx create mode 100644 client/src/assets/modals/nova/delegation.json diff --git a/.github/workflows/nova-build-temp.yaml b/.github/workflows/nova-build-temp.yaml index 3d5ef83f6..ed00fb84f 100644 --- a/.github/workflows/nova-build-temp.yaml +++ b/.github/workflows/nova-build-temp.yaml @@ -6,7 +6,7 @@ on: TARGET_COMMIT: description: "Target Commit Hash for the SDK" required: false - default: "56b19d4c12c1bf23bc716b47c36018d37d5b3168" + default: "aa1b1de58731dbbf9dd0f5e2960fd11b0056b633" environment: type: choice description: "Select the environment to deploy to" diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index ca9c5f2ea..6099bfa46 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -4,6 +4,7 @@ import foundriesMessage from "~assets/modals/stardust/alias/foundries.json"; import stateMessage from "~assets/modals/stardust/alias/state.json"; import bicMessage from "~assets/modals/nova/account/bic.json"; import validatorMessage from "~assets/modals/nova/account/validator.json"; +import delegationMessage from "~assets/modals/nova/delegation.json"; import nftMetadataMessage from "~assets/modals/stardust/nft/metadata.json"; import addressNftsMessage from "~assets/modals/stardust/address/nfts-in-wallet.json"; import TabbedSection from "../../../hoc/TabbedSection"; @@ -23,11 +24,13 @@ import NftSection from "~/app/components/nova/address/section/nft/NftSection"; import NftMetadataSection from "~/app/components/nova/address/section/nft/NftMetadataSection"; import { TransactionsHelper } from "~/helpers/nova/transactionsHelper"; import AccountValidatorSection from "./account/AccountValidatorSection"; +import DelegationSection from "./DelegationSection"; enum DEFAULT_TABS { AssocOutputs = "Outputs", NativeTokens = "Native Tokens", Nfts = "NFTs", + Delegation = "Delegation", } enum ACCOUNT_TABS { @@ -71,6 +74,13 @@ const buildDefaultTabsOptions = ( isLoading: isNftOutputsLoading, infoContent: addressNftsMessage, }, + [DEFAULT_TABS.Delegation]: { + disabled: false, + hidden: false, + counter: 0, + isLoading: false, + infoContent: delegationMessage, + }, }); const buildAccountAddressTabsOptions = ( @@ -148,6 +158,7 @@ export const AddressPageTabbedSections: React.FC, , , + , ]; const accountAddressSections = diff --git a/client/src/app/components/nova/address/section/DelegationSection.tsx b/client/src/app/components/nova/address/section/DelegationSection.tsx new file mode 100644 index 000000000..002ddec1b --- /dev/null +++ b/client/src/app/components/nova/address/section/DelegationSection.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +interface DelegationSectionProps { + readonly delegation: string | null; +} + +const DelegationSection: React.FC = ({ delegation }) => { + return ( +
+
+
+
Delegation
+
{delegation}
+
+
+
+ ); +}; + +export default DelegationSection; diff --git a/client/src/assets/modals/nova/delegation.json b/client/src/assets/modals/nova/delegation.json new file mode 100644 index 000000000..39c8143e1 --- /dev/null +++ b/client/src/assets/modals/nova/delegation.json @@ -0,0 +1,11 @@ +{ + "title": "Delegation", + "description": "

Delegators are token holders that contribute to consensus by delegating their voting power to validators of their choice.

", + "links": [ + { + "label": "Read more", + "href": "https://wiki.iota.org/learn/protocols/iota2.0/core-concepts/mana/#delegation", + "isExternal": true + } + ] +} diff --git a/setup_nova.sh b/setup_nova.sh index d036a032a..adc14bd82 100755 --- a/setup_nova.sh +++ b/setup_nova.sh @@ -1,6 +1,6 @@ #!/bin/bash SDK_DIR="iota-sdk" -TARGET_COMMIT="56b19d4c12c1bf23bc716b47c36018d37d5b3168" +TARGET_COMMIT="aa1b1de58731dbbf9dd0f5e2960fd11b0056b633" if [ ! -d "$SDK_DIR" ]; then git clone -b 2.0 git@github.com:iotaledger/iota-sdk.git From 77e905d6487becee8f74653c079fc280a076a36d Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Mon, 26 Feb 2024 14:56:39 +0100 Subject: [PATCH 22/31] fix: show validator tab if account has staking feature --- .github/workflows/nova-build-temp.yaml | 2 +- .../section/AddressPageTabbedSections.tsx | 8 ++--- .../nova/hooks/useAccountAddressState.ts | 33 ++++++++++++++----- setup_nova.sh | 2 +- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/.github/workflows/nova-build-temp.yaml b/.github/workflows/nova-build-temp.yaml index 3d5ef83f6..ed00fb84f 100644 --- a/.github/workflows/nova-build-temp.yaml +++ b/.github/workflows/nova-build-temp.yaml @@ -6,7 +6,7 @@ on: TARGET_COMMIT: description: "Target Commit Hash for the SDK" required: false - default: "56b19d4c12c1bf23bc716b47c36018d37d5b3168" + default: "aa1b1de58731dbbf9dd0f5e2960fd11b0056b633" environment: type: choice description: "Select the environment to deploy to" diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index ca9c5f2ea..a2b9644a3 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -77,7 +77,7 @@ const buildAccountAddressTabsOptions = ( isBlockIssuer: boolean, isCongestionLoading: boolean, foundriesCount: number, - hasValidatorDetails: boolean, + hasStakingFeature: boolean, isAccountFoundriesLoading: boolean, isValidatorDetailsLoading: boolean, ) => ({ @@ -94,8 +94,8 @@ const buildAccountAddressTabsOptions = ( infoContent: bicMessage, }, [ACCOUNT_TABS.Validation]: { - disabled: !hasValidatorDetails, - hidden: !hasValidatorDetails, + disabled: !hasStakingFeature, + hidden: !hasStakingFeature, isLoading: isValidatorDetailsLoading, infoContent: validatorMessage, }, @@ -201,7 +201,7 @@ export const AddressPageTabbedSections: React.FC feature.type === FeatureType.BlockIssuer, - ) as BlockIssuerFeature; - if (blockIssuerFeature) { - updatedState = { - ...updatedState, - blockIssuerFeature, - }; + if (accountOutput) { + if (!state.blockIssuerFeature) { + const blockIssuerFeature = accountOutput?.features?.find( + (feature) => feature.type === FeatureType.BlockIssuer, + ) as BlockIssuerFeature; + if (blockIssuerFeature) { + updatedState = { + ...updatedState, + blockIssuerFeature, + }; + } + } + if (!state.stakingFeature) { + const stakingFeature = accountOutput?.features?.find((feature) => feature.type === FeatureType.Staking) as StakingFeature; + if (stakingFeature) { + updatedState = { + ...updatedState, + stakingFeature, + }; + } } } diff --git a/setup_nova.sh b/setup_nova.sh index d036a032a..adc14bd82 100755 --- a/setup_nova.sh +++ b/setup_nova.sh @@ -1,6 +1,6 @@ #!/bin/bash SDK_DIR="iota-sdk" -TARGET_COMMIT="56b19d4c12c1bf23bc716b47c36018d37d5b3168" +TARGET_COMMIT="aa1b1de58731dbbf9dd0f5e2960fd11b0056b633" if [ ! -d "$SDK_DIR" ]; then git clone -b 2.0 git@github.com:iotaledger/iota-sdk.git From a0e1aeb5ce22e1b75d44b5367cf958205352bc38 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Mon, 26 Feb 2024 15:54:58 +0100 Subject: [PATCH 23/31] Add fetch delegation ids endpoint --- api/src/routes.ts | 6 ++++ .../nova/address/outputs/delegation/get.ts | 30 +++++++++++++++++++ api/src/services/nova/novaApiService.ts | 27 +++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 api/src/routes/nova/address/outputs/delegation/get.ts diff --git a/api/src/routes.ts b/api/src/routes.ts index 0cc96a5c3..0987417ef 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -235,6 +235,12 @@ export const routes: IRoute[] = [ folder: "nova/address/outputs/nft", func: "get", }, + { + path: "/nova/address/outputs/delegation/:network/:address", + method: "get", + folder: "nova/address/outputs/delegation", + func: "get", + }, { path: "/nova/output/associated/:network/:address", method: "post", diff --git a/api/src/routes/nova/address/outputs/delegation/get.ts b/api/src/routes/nova/address/outputs/delegation/get.ts new file mode 100644 index 000000000..f1aba59de --- /dev/null +++ b/api/src/routes/nova/address/outputs/delegation/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../../../factories/serviceFactory"; +import { IAddressDetailsRequest } from "../../../../../models/api/nova/IAddressDetailsRequest"; +import { IAddressDetailsResponse } from "../../../../../models/api/nova/IAddressDetailsResponse"; +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 delegation output details by address. + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: IAddressDetailsRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.address, "address"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.delegationOutputDetailsByAddress(request.address); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 7f09ba5ae..4edefd801 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -303,6 +303,33 @@ export class NovaApiService { }; } + /** + * Get the relevant basic output details for an address. + * @param addressBech32 The address in bech32 format. + * @returns The basic output details. + */ + public async delegationOutputDetailsByAddress(addressBech32: string): Promise { + let cursor: string | undefined; + let outputIds: string[] = []; + + do { + try { + const outputIdsResponse = await this.client.delegationOutputIds({ address: addressBech32, cursor: cursor ?? "" }); + + outputIds = outputIds.concat(outputIdsResponse.items); + cursor = outputIdsResponse.cursor; + } catch (e) { + logger.error(`Fetching delegation output ids failed. Cause: ${e}`); + } + } while (cursor); + + const outputResponses = await this.outputsDetails(outputIds); + + return { + outputs: outputResponses, + }; + } + /** * Get Congestion for Account * @param accountId The account address to get the congestion for. From 2b52a493dcda14308464b96c14dbf830cb04f18c Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Mon, 26 Feb 2024 15:57:00 +0100 Subject: [PATCH 24/31] fix: key string --- .../nova/address/section/AddressPageTabbedSections.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index a2b9644a3..38b39c2f0 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -163,7 +163,7 @@ export const AddressPageTabbedSections: React.FC, , ] From 77a72d8e8577270c217b88bc30653bd70ed72c6b Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Mon, 26 Feb 2024 16:19:22 +0100 Subject: [PATCH 25/31] Add hook to fetch delegation outputs --- .../nova/hooks/useAccountAddressState.ts | 13 ++++++ .../nova/hooks/useAddressDelegationOutputs.ts | 43 +++++++++++++++++++ .../nova/hooks/useAnchorAddressState.ts | 12 ++++++ .../nova/hooks/useEd25519AddressState.ts | 22 +++++++++- .../useImplicitAccountCreationAddressState.ts | 22 +++++++++- .../helpers/nova/hooks/useNftAddressState.ts | 13 ++++++ client/src/services/nova/novaApiClient.ts | 12 ++++++ 7 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 client/src/helpers/nova/hooks/useAddressDelegationOutputs.ts diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index d25c56bab..92374b46d 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -21,6 +21,7 @@ import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; import { useAccountCongestion } from "./useAccountCongestion"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; import { useAccountValidatorDetails } from "./useAccountValidatorDetails"; +import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; @@ -32,12 +33,14 @@ export interface IAccountAddressState { validatorDetails: ValidatorResponse | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; + addressDelegationOutputs: OutputResponse[] | null; foundries: string[] | null; congestion: CongestionResponse | null; isAccountDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; + isDelegationOutputsLoading: boolean; isFoundriesLoading: boolean; isCongestionLoading: boolean; isValidatorDetailsLoading: boolean; @@ -53,12 +56,14 @@ const initialState = { validatorDetails: null, addressBasicOutputs: null, addressNftOutputs: null, + addressDelegationOutputs: null, foundries: null, congestion: null, isAccountDetailsLoading: true, isAssociatedOutputsLoading: false, isBasicOutputsLoading: false, isNftOutputsLoading: false, + isDelegationOutputsLoading: false, isFoundriesLoading: false, isCongestionLoading: false, isValidatorDetailsLoading: false, @@ -85,6 +90,10 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, accountOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressDelegationOutputs, isDelegationOutputsLoading] = useAddressDelegationOutputs( + network, + state.addressDetails?.bech32 ?? null, + ); const [foundries, isFoundriesLoading] = useAccountControlledFoundries(network, state.addressDetails); const { congestion, isLoading: isCongestionLoading } = useAccountCongestion(network, state.addressDetails?.hex ?? null); const { validatorDetails, isLoading: isValidatorDetailsLoading } = useAccountValidatorDetails( @@ -115,8 +124,10 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres validatorDetails, addressBasicOutputs, addressNftOutputs, + addressDelegationOutputs, isBasicOutputsLoading, isNftOutputsLoading, + isDelegationOutputsLoading, isFoundriesLoading, isCongestionLoading, isValidatorDetailsLoading, @@ -152,11 +163,13 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres availableBalance, addressBasicOutputs, addressNftOutputs, + addressDelegationOutputs, congestion, validatorDetails, isAccountDetailsLoading, isBasicOutputsLoading, isNftOutputsLoading, + isDelegationOutputsLoading, isCongestionLoading, isValidatorDetailsLoading, ]); diff --git a/client/src/helpers/nova/hooks/useAddressDelegationOutputs.ts b/client/src/helpers/nova/hooks/useAddressDelegationOutputs.ts new file mode 100644 index 000000000..ebb48112d --- /dev/null +++ b/client/src/helpers/nova/hooks/useAddressDelegationOutputs.ts @@ -0,0 +1,43 @@ +import { OutputResponse } 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"; + +/** + * Fetch Address delegation UTXOs + * @param network The Network in context + * @param addressBech32 The address in bech32 format + * @returns The output responses and loading bool. + */ +export function useAddressDelegationOutputs(network: string, addressBech32: string | null): [OutputResponse[] | null, boolean] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [outputs, setOutputs] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + setOutputs(null); + if (addressBech32) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .delegationOutputsDetails({ network, address: addressBech32 }) + .then((response) => { + if (!response?.error && response.outputs && isMounted) { + setOutputs(response.outputs); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, addressBech32]); + + return [outputs, isLoading]; +} diff --git a/client/src/helpers/nova/hooks/useAnchorAddressState.ts b/client/src/helpers/nova/hooks/useAnchorAddressState.ts index 352dc1223..04f22327f 100644 --- a/client/src/helpers/nova/hooks/useAnchorAddressState.ts +++ b/client/src/helpers/nova/hooks/useAnchorAddressState.ts @@ -17,8 +17,10 @@ export interface IAnchorAddressState { totalBalance: number | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; + addressDelegationOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; + isDelegationOutputsLoading: boolean; isAnchorDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; } @@ -30,8 +32,10 @@ const initialState = { availableBalance: null, addressBasicOutputs: null, addressNftOutputs: null, + addressDelegationOutputs: null, isBasicOutputsLoading: false, isNftOutputsLoading: false, + isDelegationOutputsLoading: false, isAnchorDetailsLoading: true, isAssociatedOutputsLoading: false, }; @@ -56,6 +60,10 @@ export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressSt const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, anchorOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressDelegationOutputs, isDelegationOutputsLoading] = useAddressDelegationOutputs( + network, + state.addressDetails?.bech32 ?? null, + ); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -76,8 +84,10 @@ export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressSt availableBalance, addressBasicOutputs, addressNftOutputs, + addressDelegationOutputs, isBasicOutputsLoading, isNftOutputsLoading, + isDelegationOutputsLoading, isAnchorDetailsLoading, }); }, [ @@ -86,8 +96,10 @@ export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressSt availableBalance, addressBasicOutputs, addressNftOutputs, + addressDelegationOutputs, isBasicOutputsLoading, isNftOutputsLoading, + isDelegationOutputsLoading, isAnchorDetailsLoading, ]); diff --git a/client/src/helpers/nova/hooks/useEd25519AddressState.ts b/client/src/helpers/nova/hooks/useEd25519AddressState.ts index 0d9e79066..eb280e4ad 100644 --- a/client/src/helpers/nova/hooks/useEd25519AddressState.ts +++ b/client/src/helpers/nova/hooks/useEd25519AddressState.ts @@ -7,6 +7,7 @@ import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; +import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; export interface IEd25519AddressState { addressDetails: IAddressDetails | null; @@ -14,8 +15,10 @@ export interface IEd25519AddressState { availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; + addressDelegationOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; + isDelegationOutputsLoading: boolean; isAssociatedOutputsLoading: boolean; } @@ -25,8 +28,10 @@ const initialState = { availableBalance: null, addressBasicOutputs: null, addressNftOutputs: null, + addressDelegationOutputs: null, isBasicOutputsLoading: false, isNftOutputsLoading: false, + isDelegationOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -48,6 +53,10 @@ export const useEd25519AddressState = (address: Ed25519Address): [IEd25519Addres const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, null); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressDelegationOutputs, isDelegationOutputsLoading] = useAddressDelegationOutputs( + network, + state.addressDetails?.bech32 ?? null, + ); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -66,10 +75,21 @@ export const useEd25519AddressState = (address: Ed25519Address): [IEd25519Addres availableBalance, addressBasicOutputs, addressNftOutputs, + addressDelegationOutputs, isBasicOutputsLoading, isNftOutputsLoading, + isDelegationOutputsLoading, }); - }, [totalBalance, availableBalance, addressBasicOutputs, addressNftOutputs, isBasicOutputsLoading, isNftOutputsLoading]); + }, [ + totalBalance, + availableBalance, + addressBasicOutputs, + addressNftOutputs, + addressDelegationOutputs, + isBasicOutputsLoading, + isNftOutputsLoading, + isDelegationOutputsLoading, + ]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts index ea28ac024..9e6daa39e 100644 --- a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts +++ b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts @@ -8,6 +8,7 @@ import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; +import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; export interface IImplicitAccountCreationAddressState { addressDetails: IAddressDetails | null; @@ -15,8 +16,10 @@ export interface IImplicitAccountCreationAddressState { availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; + addressDelegationOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; + isDelegationOutputsLoading: boolean; isAssociatedOutputsLoading: boolean; } @@ -26,8 +29,10 @@ const initialState = { availableBalance: null, addressBasicOutputs: null, addressNftOutputs: null, + addressDelegationOutputs: null, isBasicOutputsLoading: false, isNftOutputsLoading: false, + isDelegationOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -52,6 +57,10 @@ export const useImplicitAccountCreationAddressState = ( const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, null); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressDelegationOutputs, isDelegationOutputsLoading] = useAddressDelegationOutputs( + network, + state.addressDetails?.bech32 ?? null, + ); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -71,10 +80,21 @@ export const useImplicitAccountCreationAddressState = ( availableBalance, addressBasicOutputs, addressNftOutputs, + addressDelegationOutputs, isBasicOutputsLoading, isNftOutputsLoading, + isDelegationOutputsLoading, }); - }, [totalBalance, availableBalance, addressBasicOutputs, addressNftOutputs, isBasicOutputsLoading, isBasicOutputsLoading]); + }, [ + totalBalance, + availableBalance, + addressBasicOutputs, + addressNftOutputs, + addressDelegationOutputs, + isBasicOutputsLoading, + isNftOutputsLoading, + isDelegationOutputsLoading, + ]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useNftAddressState.ts b/client/src/helpers/nova/hooks/useNftAddressState.ts index 0852ecdd0..1da862b5b 100644 --- a/client/src/helpers/nova/hooks/useNftAddressState.ts +++ b/client/src/helpers/nova/hooks/useNftAddressState.ts @@ -9,6 +9,7 @@ import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; +import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; export interface INftAddressState { addressDetails: IAddressDetails | null; @@ -17,8 +18,10 @@ export interface INftAddressState { availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; + addressDelegationOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; + isDelegationOutputsLoading: boolean; isNftDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; } @@ -31,8 +34,10 @@ const initialState = { availableBalance: null, addressBasicOutputs: null, addressNftOutputs: null, + addressDelegationOutputs: null, isBasicOutputsLoading: false, isNftOutputsLoading: false, + isDelegationOutputsLoading: false, isAssociatedOutputsLoading: false, }; @@ -56,6 +61,10 @@ export const useNftAddressState = (address: NftAddress): [INftAddressState, Reac const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, nftOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); + const [addressDelegationOutputs, isDelegationOutputsLoading] = useAddressDelegationOutputs( + network, + state.addressDetails?.bech32 ?? null, + ); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -77,8 +86,10 @@ export const useNftAddressState = (address: NftAddress): [INftAddressState, Reac isNftDetailsLoading, addressBasicOutputs, addressNftOutputs, + addressDelegationOutputs, isBasicOutputsLoading, isNftOutputsLoading, + isDelegationOutputsLoading, }); }, [ nftOutput, @@ -87,8 +98,10 @@ export const useNftAddressState = (address: NftAddress): [INftAddressState, Reac isNftDetailsLoading, addressBasicOutputs, addressNftOutputs, + addressDelegationOutputs, isBasicOutputsLoading, isNftOutputsLoading, + isDelegationOutputsLoading, ]); return [state, setState]; diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index fb098467f..4c54b49c6 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -157,6 +157,18 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/address/outputs/nft/${request.network}/${request.address}`, "get"); } + /** + * Get the delegation outputs details of an address. + * @param request The Address Delegation outputs request. + * @returns The Address outputs response + */ + public async delegationOutputsDetails(request: IAddressDetailsRequest): Promise { + return this.callApi( + `nova/address/outputs/delegation/${request.network}/${request.address}`, + "get", + ); + } + /** * Get the associated outputs. * @param request The request to send. From 4a6a170bb64b87f11133e8c89bc6b9ab96ad6d68 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Mon, 26 Feb 2024 16:38:19 +0100 Subject: [PATCH 26/31] Show delegation outputs total amount --- .../address/section/AddressPageTabbedSections.tsx | 15 ++++++++++----- .../nova/address/section/DelegationSection.tsx | 15 +++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 8d465fb86..2866e4323 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -51,8 +51,10 @@ const buildDefaultTabsOptions = ( tokensCount: number, nftsCount: number, associatedOutputCount: number, + delegationCount: number, isNativeTokensLoading: boolean, isNftOutputsLoading: boolean, + isDelegationOutputsLoading: boolean, ) => ({ [DEFAULT_TABS.AssocOutputs]: { disabled: associatedOutputCount === 0, @@ -75,10 +77,10 @@ const buildDefaultTabsOptions = ( infoContent: addressNftsMessage, }, [DEFAULT_TABS.Delegation]: { - disabled: false, - hidden: false, - counter: 0, - isLoading: false, + disabled: delegationCount === 0, + hidden: delegationCount === 0, + counter: delegationCount, + isLoading: isDelegationOutputsLoading, infoContent: delegationMessage, }, }); @@ -158,7 +160,7 @@ export const AddressPageTabbedSections: React.FC, , , - , + , ]; const accountAddressSections = @@ -192,12 +194,15 @@ export const AddressPageTabbedSections: React.FC = ({ delegation }) => { +const DelegationSection: React.FC = ({ outputs }) => { + const totalAmount = outputs?.reduce((acc, output) => acc + BigInt(output.output.amount), BigInt(0)); + + if (outputs === null) { + return null; + } + return (
-
Delegation
-
{delegation}
+
Total amount
+
{totalAmount?.toString()}
From 296622534b8015781e16764e5e8f295f9d312a39 Mon Sep 17 00:00:00 2001 From: Bran <52735957+brancoder@users.noreply.github.com> Date: Mon, 26 Feb 2024 17:22:34 +0100 Subject: [PATCH 27/31] Feat: Add validators tab to account address page (#1183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add validators tab to account address page * fix: update sdk commit * fix: show validator tab if account has staking feature * fix: key string --------- Co-authored-by: Mario Co-authored-by: Begoña Álvarez de la Cruz --- .github/workflows/nova-build-temp.yaml | 2 +- .../nova/IAccountValidatorDetailsRequest.ts | 11 ++++ .../nova/IAccountValidatorDetailsResponse.ts | 11 ++++ api/src/routes.ts | 6 ++ api/src/routes/nova/account/validator/get.ts | 30 ++++++++++ api/src/services/nova/novaApiService.ts | 20 +++++++ .../section/AddressPageTabbedSections.tsx | 17 ++++++ .../account/AccountValidatorSection.tsx | 55 +++++++++++++++++++ .../assets/modals/nova/account/validator.json | 11 ++++ .../nova/hooks/useAccountAddressState.ts | 47 +++++++++++++--- .../nova/hooks/useAccountValidatorDetails.ts | 48 ++++++++++++++++ .../nova/IAccountValidatorDetailsRequest.ts | 11 ++++ .../nova/IAccountValidatorDetailsResponse.ts | 9 +++ client/src/services/nova/novaApiClient.ts | 11 ++++ setup_nova.sh | 2 +- 15 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 api/src/models/api/nova/IAccountValidatorDetailsRequest.ts create mode 100644 api/src/models/api/nova/IAccountValidatorDetailsResponse.ts create mode 100644 api/src/routes/nova/account/validator/get.ts create mode 100644 client/src/app/components/nova/address/section/account/AccountValidatorSection.tsx create mode 100644 client/src/assets/modals/nova/account/validator.json create mode 100644 client/src/helpers/nova/hooks/useAccountValidatorDetails.ts create mode 100644 client/src/models/api/nova/IAccountValidatorDetailsRequest.ts create mode 100644 client/src/models/api/nova/IAccountValidatorDetailsResponse.ts diff --git a/.github/workflows/nova-build-temp.yaml b/.github/workflows/nova-build-temp.yaml index 7b718a706..ed00fb84f 100644 --- a/.github/workflows/nova-build-temp.yaml +++ b/.github/workflows/nova-build-temp.yaml @@ -6,7 +6,7 @@ on: TARGET_COMMIT: description: "Target Commit Hash for the SDK" required: false - default: "fc9f0f56bb5cfc146993e53aa9656ded220734e1" + default: "aa1b1de58731dbbf9dd0f5e2960fd11b0056b633" environment: type: choice description: "Select the environment to deploy to" diff --git a/api/src/models/api/nova/IAccountValidatorDetailsRequest.ts b/api/src/models/api/nova/IAccountValidatorDetailsRequest.ts new file mode 100644 index 000000000..0e4b099da --- /dev/null +++ b/api/src/models/api/nova/IAccountValidatorDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface IAccountValidatorDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the validator details for. + */ + accountId: string; +} diff --git a/api/src/models/api/nova/IAccountValidatorDetailsResponse.ts b/api/src/models/api/nova/IAccountValidatorDetailsResponse.ts new file mode 100644 index 000000000..13b205e30 --- /dev/null +++ b/api/src/models/api/nova/IAccountValidatorDetailsResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { ValidatorResponse } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface IAccountValidatorDetailsResponse extends IResponse { + /** + * The account validator response. + */ + validatorDetails?: ValidatorResponse; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index f13155368..776b6cecc 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -260,6 +260,12 @@ export const routes: IRoute[] = [ folder: "nova/account/congestion", func: "get", }, + { + path: "/nova/account/validator/:network/:accountId", + method: "get", + folder: "nova/account/validator", + func: "get", + }, { 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/account/validator/get.ts b/api/src/routes/nova/account/validator/get.ts new file mode 100644 index 000000000..f4e837c4f --- /dev/null +++ b/api/src/routes/nova/account/validator/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { IAccountValidatorDetailsRequest } from "../../../../models/api/nova/IAccountValidatorDetailsRequest"; +import { IAccountValidatorDetailsResponse } from "../../../../models/api/nova/IAccountValidatorDetailsResponse"; +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"; + +/** + * Get validator details for Account address + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: IAccountValidatorDetailsRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.accountId, "accountId"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.getAccountValidatorDetails(request.accountId); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index ddd3516c1..0fc9b4456 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -7,6 +7,7 @@ import logger from "../../logger"; import { IFoundriesResponse } from "../../models/api/nova/foundry/IFoundriesResponse"; import { IFoundryResponse } from "../../models/api/nova/foundry/IFoundryResponse"; import { IAccountDetailsResponse } from "../../models/api/nova/IAccountDetailsResponse"; +import { IAccountValidatorDetailsResponse } from "../../models/api/nova/IAccountValidatorDetailsResponse"; import { IAddressDetailsResponse } from "../../models/api/nova/IAddressDetailsResponse"; import { IAnchorDetailsResponse } from "../../models/api/nova/IAnchorDetailsResponse"; import { IBlockDetailsResponse } from "../../models/api/nova/IBlockDetailsResponse"; @@ -322,6 +323,25 @@ export class NovaApiService { } } + /** + * Get validator details for Account + * @param accountId The account id to get the validator details for. + * @returns The Congestion. + */ + public async getAccountValidatorDetails(accountId: string): Promise { + try { + const response = await this.client.getValidator(accountId); + + if (response) { + return { + validatorDetails: response, + }; + } + } catch { + return { message: "Validator details not found" }; + } + } + /** * Get the output mana rewards. * @param outputId The outputId to get the rewards for. diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 59910d959..38b39c2f0 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -3,6 +3,7 @@ import associatedOuputsMessage from "~assets/modals/stardust/address/associated- import foundriesMessage from "~assets/modals/stardust/alias/foundries.json"; import stateMessage from "~assets/modals/stardust/alias/state.json"; import bicMessage from "~assets/modals/nova/account/bic.json"; +import validatorMessage from "~assets/modals/nova/account/validator.json"; import nftMetadataMessage from "~assets/modals/stardust/nft/metadata.json"; import addressNftsMessage from "~assets/modals/stardust/address/nfts-in-wallet.json"; import TabbedSection from "../../../hoc/TabbedSection"; @@ -21,6 +22,7 @@ import AnchorStateSection from "./anchor/AnchorStateSection"; import NftSection from "~/app/components/nova/address/section/nft/NftSection"; import NftMetadataSection from "~/app/components/nova/address/section/nft/NftMetadataSection"; import { TransactionsHelper } from "~/helpers/nova/transactionsHelper"; +import AccountValidatorSection from "./account/AccountValidatorSection"; enum DEFAULT_TABS { AssocOutputs = "Outputs", @@ -31,6 +33,7 @@ enum DEFAULT_TABS { enum ACCOUNT_TABS { BlockIssuance = "Block Issuance", Foundries = "Foundries", + Validation = "Validation", } enum ANCHOR_TABS { @@ -74,7 +77,9 @@ const buildAccountAddressTabsOptions = ( isBlockIssuer: boolean, isCongestionLoading: boolean, foundriesCount: number, + hasStakingFeature: boolean, isAccountFoundriesLoading: boolean, + isValidatorDetailsLoading: boolean, ) => ({ [ACCOUNT_TABS.Foundries]: { disabled: foundriesCount === 0, @@ -88,6 +93,12 @@ const buildAccountAddressTabsOptions = ( isLoading: isCongestionLoading, infoContent: bicMessage, }, + [ACCOUNT_TABS.Validation]: { + disabled: !hasStakingFeature, + hidden: !hasStakingFeature, + isLoading: isValidatorDetailsLoading, + infoContent: validatorMessage, + }, }); const buildAnchorAddressTabsOptions = (isAnchorStateTabDisabled: boolean, isAnchorDetailsLoading: boolean) => ({ @@ -151,6 +162,10 @@ export const AddressPageTabbedSections: React.FC, + , ] : null; @@ -186,7 +201,9 @@ export const AddressPageTabbedSections: React.FC = ({ validatorDetails }) => { + if (!validatorDetails) { + return null; + } + + const delegatedStake = BigInt(validatorDetails.poolStake) - BigInt(validatorDetails.validatorStake); + + return ( +
+
+
+
Active
+
{validatorDetails.active ? "Yes" : "No"}
+
+
+
Staking End Epoch
+
{validatorDetails.stakingEndEpoch}
+
+
+
Pool Stake
+
{String(validatorDetails.poolStake)}
+
+
+
Validator Stake
+
{String(validatorDetails.validatorStake)}
+
+
+
Delegated Stake
+
{String(delegatedStake)}
+
+
+
Fixed Cost
+
{Number(validatorDetails?.fixedCost)}
+
+
+
Latest Supported Protocol Version
+
{validatorDetails?.latestSupportedProtocolVersion}
+
+
+
Latest Supported Protocol Hash
+
{validatorDetails?.latestSupportedProtocolHash}
+
+
+
+ ); +}; + +export default AccountValidatorSection; diff --git a/client/src/assets/modals/nova/account/validator.json b/client/src/assets/modals/nova/account/validator.json new file mode 100644 index 000000000..a512acaed --- /dev/null +++ b/client/src/assets/modals/nova/account/validator.json @@ -0,0 +1,11 @@ +{ + "title": "Validator", + "description": "

Validators are special nodes that issue validation blocks.

", + "links": [ + { + "label": "Read more", + "href": "https://wiki.iota.org/learn/protocols/iota2.0/core-concepts/validators", + "isExternal": true + } + ] +} diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index 2a1438b63..d25c56bab 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -6,6 +6,8 @@ import { CongestionResponse, FeatureType, OutputResponse, + StakingFeature, + ValidatorResponse, } from "@iota/sdk-wasm-nova/web"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useAccountDetails } from "./useAccountDetails"; @@ -18,6 +20,7 @@ import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutp import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; import { useAccountCongestion } from "./useAccountCongestion"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; +import { useAccountValidatorDetails } from "./useAccountValidatorDetails"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; @@ -25,6 +28,8 @@ export interface IAccountAddressState { totalBalance: number | null; availableBalance: number | null; blockIssuerFeature: BlockIssuerFeature | null; + stakingFeature: StakingFeature | null; + validatorDetails: ValidatorResponse | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; foundries: string[] | null; @@ -35,6 +40,7 @@ export interface IAccountAddressState { isNftOutputsLoading: boolean; isFoundriesLoading: boolean; isCongestionLoading: boolean; + isValidatorDetailsLoading: boolean; } const initialState = { @@ -43,6 +49,8 @@ const initialState = { totalBalance: null, availableBalance: null, blockIssuerFeature: null, + stakingFeature: null, + validatorDetails: null, addressBasicOutputs: null, addressNftOutputs: null, foundries: null, @@ -53,6 +61,7 @@ const initialState = { isNftOutputsLoading: false, isFoundriesLoading: false, isCongestionLoading: false, + isValidatorDetailsLoading: false, }; /** @@ -72,11 +81,16 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres ); const { accountOutput, isLoading: isAccountDetailsLoading } = useAccountDetails(network, address.accountId); + const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, accountOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); const [addressNftOutputs, isNftOutputsLoading] = useAddressNftOutputs(network, state.addressDetails?.bech32 ?? null); const [foundries, isFoundriesLoading] = useAccountControlledFoundries(network, state.addressDetails); const { congestion, isLoading: isCongestionLoading } = useAccountCongestion(network, state.addressDetails?.hex ?? null); + const { validatorDetails, isLoading: isValidatorDetailsLoading } = useAccountValidatorDetails( + network, + state.addressDetails?.hex ?? null, + ); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -98,23 +112,36 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres availableBalance, foundries, congestion, + validatorDetails, addressBasicOutputs, addressNftOutputs, isBasicOutputsLoading, isNftOutputsLoading, isFoundriesLoading, isCongestionLoading, + isValidatorDetailsLoading, }; - if (accountOutput && !state.blockIssuerFeature) { - const blockIssuerFeature = accountOutput?.features?.find( - (feature) => feature.type === FeatureType.BlockIssuer, - ) as BlockIssuerFeature; - if (blockIssuerFeature) { - updatedState = { - ...updatedState, - blockIssuerFeature, - }; + if (accountOutput) { + if (!state.blockIssuerFeature) { + const blockIssuerFeature = accountOutput?.features?.find( + (feature) => feature.type === FeatureType.BlockIssuer, + ) as BlockIssuerFeature; + if (blockIssuerFeature) { + updatedState = { + ...updatedState, + blockIssuerFeature, + }; + } + } + if (!state.stakingFeature) { + const stakingFeature = accountOutput?.features?.find((feature) => feature.type === FeatureType.Staking) as StakingFeature; + if (stakingFeature) { + updatedState = { + ...updatedState, + stakingFeature, + }; + } } } @@ -126,10 +153,12 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres addressBasicOutputs, addressNftOutputs, congestion, + validatorDetails, isAccountDetailsLoading, isBasicOutputsLoading, isNftOutputsLoading, isCongestionLoading, + isValidatorDetailsLoading, ]); return [state, setState]; diff --git a/client/src/helpers/nova/hooks/useAccountValidatorDetails.ts b/client/src/helpers/nova/hooks/useAccountValidatorDetails.ts new file mode 100644 index 000000000..62c0450df --- /dev/null +++ b/client/src/helpers/nova/hooks/useAccountValidatorDetails.ts @@ -0,0 +1,48 @@ +import { ValidatorResponse } from "@iota/sdk-wasm-nova/web"; +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"; + +/** + * Fetch account validator information + * @param network The Network in context + * @param accountId The account id + * @returns The output response and loading bool. + */ +export function useAccountValidatorDetails( + network: string, + accountId: string | null, +): { validatorDetails: ValidatorResponse | null; isLoading: boolean } { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [validatorDetails, setValidatorDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (accountId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .getAccountValidatorDetails({ + network, + accountId, + }) + .then((response) => { + if (!response?.error && isMounted) { + setValidatorDetails(response.validatorDetails ?? null); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, accountId]); + + return { validatorDetails, isLoading }; +} diff --git a/client/src/models/api/nova/IAccountValidatorDetailsRequest.ts b/client/src/models/api/nova/IAccountValidatorDetailsRequest.ts new file mode 100644 index 000000000..0e4b099da --- /dev/null +++ b/client/src/models/api/nova/IAccountValidatorDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface IAccountValidatorDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the validator details for. + */ + accountId: string; +} diff --git a/client/src/models/api/nova/IAccountValidatorDetailsResponse.ts b/client/src/models/api/nova/IAccountValidatorDetailsResponse.ts new file mode 100644 index 000000000..8a41fba3f --- /dev/null +++ b/client/src/models/api/nova/IAccountValidatorDetailsResponse.ts @@ -0,0 +1,9 @@ +import { ValidatorResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; + +export interface IAccountValidatorDetailsResponse extends IResponse { + /** + * The account validator response. + */ + validatorDetails?: ValidatorResponse; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index a441e0722..81152f01f 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -35,6 +35,8 @@ 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 { IAccountValidatorDetailsRequest } from "~/models/api/nova/IAccountValidatorDetailsRequest"; +import { IAccountValidatorDetailsResponse } from "~/models/api/nova/IAccountValidatorDetailsResponse"; /** * Class to handle api communications on nova. @@ -179,6 +181,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/account/congestion/${request.network}/${request.accountId}`, "get"); } + /** + * Get the account validator info. + * @param request The request to send. + * @returns The response from the request. + */ + public async getAccountValidatorDetails(request: IAccountValidatorDetailsRequest): Promise { + return this.callApi(`nova/account/validator/${request.network}/${request.accountId}`, "get"); + } + /** * Get the output mana rewards. * @param request The request to send. diff --git a/setup_nova.sh b/setup_nova.sh index b69fcd007..adc14bd82 100755 --- a/setup_nova.sh +++ b/setup_nova.sh @@ -1,6 +1,6 @@ #!/bin/bash SDK_DIR="iota-sdk" -TARGET_COMMIT="fc9f0f56bb5cfc146993e53aa9656ded220734e1" +TARGET_COMMIT="aa1b1de58731dbbf9dd0f5e2960fd11b0056b633" if [ ! -d "$SDK_DIR" ]; then git clone -b 2.0 git@github.com:iotaledger/iota-sdk.git From f51570f261d8436c5c15d660574072d7d048a755 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Tue, 27 Feb 2024 12:00:16 +0100 Subject: [PATCH 28/31] add delegation output rewards to Delegation Section --- .../api/nova/IDelegationDetailsResponse.ts | 11 ++ api/src/models/api/nova/IRewardsResponse.ts | 7 +- api/src/services/nova/novaApiService.ts | 33 ++++- .../section/AddressPageTabbedSections.tsx | 4 +- .../address/section/DelegationSection.tsx | 27 ---- .../section/delegation/DelegationSection.scss | 96 ++++++++++++++ .../section/delegation/DelegationSection.tsx | 124 ++++++++++++++++++ .../nova/hooks/useAccountAddressState.ts | 3 +- .../nova/hooks/useAddressDelegationOutputs.ts | 6 +- .../nova/hooks/useAnchorAddressState.ts | 4 +- .../nova/hooks/useEd25519AddressState.ts | 3 +- .../useImplicitAccountCreationAddressState.ts | 3 +- .../helpers/nova/hooks/useNftAddressState.ts | 3 +- .../api/nova/IDelegationDetailsResponse.ts | 9 ++ .../src/models/api/nova/IRewardsResponse.ts | 7 +- client/src/services/nova/novaApiClient.ts | 5 +- 16 files changed, 302 insertions(+), 43 deletions(-) create mode 100644 api/src/models/api/nova/IDelegationDetailsResponse.ts delete mode 100644 client/src/app/components/nova/address/section/DelegationSection.tsx create mode 100644 client/src/app/components/nova/address/section/delegation/DelegationSection.scss create mode 100644 client/src/app/components/nova/address/section/delegation/DelegationSection.tsx create mode 100644 client/src/models/api/nova/IDelegationDetailsResponse.ts diff --git a/api/src/models/api/nova/IDelegationDetailsResponse.ts b/api/src/models/api/nova/IDelegationDetailsResponse.ts new file mode 100644 index 000000000..e6bf3bdfa --- /dev/null +++ b/api/src/models/api/nova/IDelegationDetailsResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { IResponse } from "./IResponse"; +import { IRewardsResponse } from "./IRewardsResponse"; + +export interface IDelegationDetailsResponse extends IResponse { + /** + * The outputs data. + */ + outputs?: IRewardsResponse[]; +} diff --git a/api/src/models/api/nova/IRewardsResponse.ts b/api/src/models/api/nova/IRewardsResponse.ts index 587c6dc14..26790fee3 100644 --- a/api/src/models/api/nova/IRewardsResponse.ts +++ b/api/src/models/api/nova/IRewardsResponse.ts @@ -1,6 +1,6 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import { ManaRewardsResponse } from "@iota/sdk-nova"; +import { ManaRewardsResponse, OutputResponse } from "@iota/sdk-nova"; import { IResponse } from "./IResponse"; export interface IRewardsResponse extends IResponse { @@ -13,4 +13,9 @@ export interface IRewardsResponse extends IResponse { * The output mana rewards. */ manaRewards?: ManaRewardsResponse; + + /** + * The output data. + */ + output: OutputResponse; } diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index ce44139f7..875cfd46a 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -13,6 +13,7 @@ import { IAnchorDetailsResponse } from "../../models/api/nova/IAnchorDetailsResp import { IBlockDetailsResponse } from "../../models/api/nova/IBlockDetailsResponse"; import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; import { ICongestionResponse } from "../../models/api/nova/ICongestionResponse"; +import { IDelegationDetailsResponse } from "../../models/api/nova/IDelegationDetailsResponse"; import { INftDetailsResponse } from "../../models/api/nova/INftDetailsResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; import { IRewardsResponse } from "../../models/api/nova/IRewardsResponse"; @@ -270,6 +271,25 @@ export class NovaApiService { } } + /** + * Get the outputs mana rewards. + * @param outputIds The output ids to get the mana rewards for. + * @returns The rewards details. + */ + public async outputsRewardsDetails(outputIds: string[]): Promise { + const promises: Promise[] = []; + + for (const outputId of outputIds) { + const promise = this.getRewards(outputId); + promises.push(promise); + } + try { + return await Promise.all(promises); + } catch (e) { + logger.error(`Fetching outputs rewards failed. Cause: ${e}`); + } + } + /** * Get the relevant basic output details for an address. * @param addressBech32 The address in bech32 format. @@ -328,9 +348,10 @@ export class NovaApiService { * @param addressBech32 The address in bech32 format. * @returns The basic output details. */ - public async delegationOutputDetailsByAddress(addressBech32: string): Promise { + public async delegationOutputDetailsByAddress(addressBech32: string): Promise { let cursor: string | undefined; let outputIds: string[] = []; + const delegationResponse: { output: OutputResponse & IRewardsResponse }[] = []; do { try { @@ -343,10 +364,18 @@ export class NovaApiService { } } while (cursor); + const outputRewards = await this.outputsRewardsDetails(outputIds); const outputResponses = await this.outputsDetails(outputIds); + for (const outputResponse of outputResponses) { + const matchingReward = outputRewards.find((outputReward) => outputReward.outputId === outputResponse.metadata.outputId); + if (matchingReward) { + delegationResponse.push({ ...matchingReward, output: outputResponse }); + } + } + return { - outputs: outputResponses, + outputs: delegationResponse, }; } diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index 6b2175093..c491dc978 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -24,7 +24,7 @@ import NftSection from "~/app/components/nova/address/section/nft/NftSection"; import NftMetadataSection from "~/app/components/nova/address/section/nft/NftMetadataSection"; import { TransactionsHelper } from "~/helpers/nova/transactionsHelper"; import AccountValidatorSection from "./account/AccountValidatorSection"; -import DelegationSection from "./DelegationSection"; +import DelegationSection from "./delegation/DelegationSection"; enum DEFAULT_TABS { AssocOutputs = "Outputs", @@ -160,7 +160,7 @@ export const AddressPageTabbedSections: React.FC, , , - , + , ]; const accountAddressSections = diff --git a/client/src/app/components/nova/address/section/DelegationSection.tsx b/client/src/app/components/nova/address/section/DelegationSection.tsx deleted file mode 100644 index f09df841c..000000000 --- a/client/src/app/components/nova/address/section/DelegationSection.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { OutputResponse } from "@iota/sdk-wasm-nova/web"; -import React from "react"; - -interface DelegationSectionProps { - readonly outputs: OutputResponse[] | null; -} - -const DelegationSection: React.FC = ({ outputs }) => { - const totalAmount = outputs?.reduce((acc, output) => acc + BigInt(output.output.amount), BigInt(0)); - - if (outputs === null) { - return null; - } - - return ( -
-
-
-
Total amount
-
{totalAmount?.toString()}
-
-
-
- ); -}; - -export default DelegationSection; diff --git a/client/src/app/components/nova/address/section/delegation/DelegationSection.scss b/client/src/app/components/nova/address/section/delegation/DelegationSection.scss new file mode 100644 index 000000000..8dac0126f --- /dev/null +++ b/client/src/app/components/nova/address/section/delegation/DelegationSection.scss @@ -0,0 +1,96 @@ +@import "../../../../../../scss/fonts"; +@import "../../../../../../scss/media-queries"; +@import "../../../../../../scss/mixins"; +@import "../../../../../../scss/variables"; +@import "../../../../../../scss/themes"; + +.table--delegation { + width: 100%; + border-spacing: 12px 28px; + border-collapse: separate; + + @include tablet-down { + display: none; + } + + tr { + @include font-size(14px); + + color: $gray-7; + font-family: $inter; + letter-spacing: 0.5px; + + th { + @include font-size(12px); + + color: $gray-6; + font-weight: 600; + text-align: left; + text-transform: uppercase; + } + + td { + color: var(--body-color); + + &.highlight { + color: var(--link-color); + @include font-size(14px); + + font-family: $ibm-plex-mono; + font-weight: normal; + letter-spacing: 0.02em; + line-height: 20px; + + a { + max-width: 200px; + } + } + &.truncate { + max-width: 150px; + } + } + } +} + +.delegation-cards { + display: none; + + @include tablet-down { + display: block; + + .card--delegation { + margin-bottom: 48px; + + .field { + margin-bottom: 8px; + + .label { + color: $gray-6; + font-family: $inter; + letter-spacing: 0.5px; + + @include font-size(14px, 21px); + } + + .value { + @include font-size(14px, 21px); + + color: var(--body-color); + font-family: $metropolis; + font-weight: 700; + + .highlight { + color: var(--link-color); + @include font-size(14px); + + font-family: $ibm-plex-mono; + font-weight: normal; + letter-spacing: 0.02em; + line-height: 20px; + max-width: 200px; + } + } + } + } + } +} diff --git a/client/src/app/components/nova/address/section/delegation/DelegationSection.tsx b/client/src/app/components/nova/address/section/delegation/DelegationSection.tsx new file mode 100644 index 000000000..a79d6620d --- /dev/null +++ b/client/src/app/components/nova/address/section/delegation/DelegationSection.tsx @@ -0,0 +1,124 @@ +import { DelegationOutput, Utils } from "@iota/sdk-wasm-nova/web"; +import React, { useEffect, useState } from "react"; +import Pagination from "~/app/components/Pagination"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { IRewardsResponse } from "~/models/api/nova/IRewardsResponse"; +import "./DelegationSection.scss"; + +interface DelegationSectionProps { + readonly delegationDetails: IRewardsResponse[] | null; +} + +const PAGE_SIZE: number = 10; + +const DelegationSection: React.FC = ({ delegationDetails }) => { + const { name: network, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const [pageNumber, setPageNumber] = useState(1); + const [currentPage, setCurrentPage] = useState([]); + + const totalAmount = delegationDetails?.reduce((acc, delegation) => acc + BigInt(delegation.output?.output?.amount ?? 0), BigInt(0)); + const totalRewards = delegationDetails?.reduce((acc, delegation) => acc + BigInt(delegation.manaRewards?.rewards ?? 0), BigInt(0)); + + useEffect(() => { + const from = (pageNumber - 1) * PAGE_SIZE; + const to = from + PAGE_SIZE; + if (delegationDetails) { + setCurrentPage(delegationDetails.slice(from, to)); + } + }, [delegationDetails, pageNumber]); + + if (delegationDetails === null) { + return null; + } + + return ( +
+
+
+
Total amount
+
{totalAmount?.toString()}
+
+
+
Total rewards
+
{totalRewards?.toString()}
+
+
+ + + + + + + + + + + + {currentPage.map((delegation, k) => { + const validatorAddress = Utils.accountIdToBech32( + (delegation.output?.output as DelegationOutput).validatorId, + bech32Hrp, + ); + + return ( + + + + + + + ); + })} + +
Output IdValidator addressAmountRewards
+ + + + {delegation.output?.output.amount.toString() ?? "-"}{delegation.manaRewards?.rewards.toString() ?? "-"}
+ + {/* Only visible in mobile*/} +
+ {currentPage.map((delegation, k) => { + const validatorAddress = Utils.accountIdToBech32( + (delegation.output?.output as DelegationOutput).validatorId, + bech32Hrp, + ); + return ( +
+
+
Output Id
+
+ +
+
+
+
Validator Id
+
+ +
+
+
+
Amount
+
{delegation.output?.output.amount.toString() ?? "-"}
+
+
+
Rewards
+
{delegation.manaRewards?.rewards.toString() ?? "-"}
+
+
+ ); + })} +
+ setPageNumber(number)} + /> +
+ ); +}; + +export default DelegationSection; diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index 92374b46d..f535b9afd 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -22,6 +22,7 @@ import { useAccountCongestion } from "./useAccountCongestion"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; import { useAccountValidatorDetails } from "./useAccountValidatorDetails"; import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; +import { IRewardsResponse } from "~/models/api/nova/IRewardsResponse"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; @@ -33,7 +34,7 @@ export interface IAccountAddressState { validatorDetails: ValidatorResponse | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; - addressDelegationOutputs: OutputResponse[] | null; + addressDelegationOutputs: IRewardsResponse[] | null; foundries: string[] | null; congestion: CongestionResponse | null; isAccountDetailsLoading: boolean; diff --git a/client/src/helpers/nova/hooks/useAddressDelegationOutputs.ts b/client/src/helpers/nova/hooks/useAddressDelegationOutputs.ts index ebb48112d..54aec7132 100644 --- a/client/src/helpers/nova/hooks/useAddressDelegationOutputs.ts +++ b/client/src/helpers/nova/hooks/useAddressDelegationOutputs.ts @@ -1,9 +1,9 @@ -import { OutputResponse } 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 { IRewardsResponse } from "~/models/api/nova/IRewardsResponse"; /** * Fetch Address delegation UTXOs @@ -11,10 +11,10 @@ import { NovaApiClient } from "~/services/nova/novaApiClient"; * @param addressBech32 The address in bech32 format * @returns The output responses and loading bool. */ -export function useAddressDelegationOutputs(network: string, addressBech32: string | null): [OutputResponse[] | null, boolean] { +export function useAddressDelegationOutputs(network: string, addressBech32: string | null): [IRewardsResponse[] | null, boolean] { const isMounted = useIsMounted(); const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); - const [outputs, setOutputs] = useState(null); + const [outputs, setOutputs] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { diff --git a/client/src/helpers/nova/hooks/useAnchorAddressState.ts b/client/src/helpers/nova/hooks/useAnchorAddressState.ts index 04f22327f..e47fb113b 100644 --- a/client/src/helpers/nova/hooks/useAnchorAddressState.ts +++ b/client/src/helpers/nova/hooks/useAnchorAddressState.ts @@ -9,6 +9,8 @@ import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; +import { IRewardsResponse } from "~/models/api/nova/IRewardsResponse"; +import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; export interface IAnchorAddressState { addressDetails: IAddressDetails | null; @@ -17,7 +19,7 @@ export interface IAnchorAddressState { totalBalance: number | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; - addressDelegationOutputs: OutputResponse[] | null; + addressDelegationOutputs: IRewardsResponse[] | null; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; isDelegationOutputsLoading: boolean; diff --git a/client/src/helpers/nova/hooks/useEd25519AddressState.ts b/client/src/helpers/nova/hooks/useEd25519AddressState.ts index eb280e4ad..4cdee9d4a 100644 --- a/client/src/helpers/nova/hooks/useEd25519AddressState.ts +++ b/client/src/helpers/nova/hooks/useEd25519AddressState.ts @@ -8,6 +8,7 @@ import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; +import { IRewardsResponse } from "~/models/api/nova/IRewardsResponse"; export interface IEd25519AddressState { addressDetails: IAddressDetails | null; @@ -15,7 +16,7 @@ export interface IEd25519AddressState { availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; - addressDelegationOutputs: OutputResponse[] | null; + addressDelegationOutputs: IRewardsResponse[] | null; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; isDelegationOutputsLoading: boolean; diff --git a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts index 9e6daa39e..e61dc8562 100644 --- a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts +++ b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts @@ -9,6 +9,7 @@ import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; +import { IRewardsResponse } from "~/models/api/nova/IRewardsResponse"; export interface IImplicitAccountCreationAddressState { addressDetails: IAddressDetails | null; @@ -16,7 +17,7 @@ export interface IImplicitAccountCreationAddressState { availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; - addressDelegationOutputs: OutputResponse[] | null; + addressDelegationOutputs: IRewardsResponse[] | null; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; isDelegationOutputsLoading: boolean; diff --git a/client/src/helpers/nova/hooks/useNftAddressState.ts b/client/src/helpers/nova/hooks/useNftAddressState.ts index 1da862b5b..2c116ddca 100644 --- a/client/src/helpers/nova/hooks/useNftAddressState.ts +++ b/client/src/helpers/nova/hooks/useNftAddressState.ts @@ -10,6 +10,7 @@ import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAddressNftOutputs } from "~/helpers/nova/hooks/useAddressNftOutputs"; import { useAddressDelegationOutputs } from "./useAddressDelegationOutputs"; +import { IRewardsResponse } from "~/models/api/nova/IRewardsResponse"; export interface INftAddressState { addressDetails: IAddressDetails | null; @@ -18,7 +19,7 @@ export interface INftAddressState { availableBalance: number | null; addressBasicOutputs: OutputResponse[] | null; addressNftOutputs: OutputResponse[] | null; - addressDelegationOutputs: OutputResponse[] | null; + addressDelegationOutputs: IRewardsResponse[] | null; isBasicOutputsLoading: boolean; isNftOutputsLoading: boolean; isDelegationOutputsLoading: boolean; diff --git a/client/src/models/api/nova/IDelegationDetailsResponse.ts b/client/src/models/api/nova/IDelegationDetailsResponse.ts new file mode 100644 index 000000000..e01174355 --- /dev/null +++ b/client/src/models/api/nova/IDelegationDetailsResponse.ts @@ -0,0 +1,9 @@ +import { IResponse } from "./IResponse"; +import { IRewardsResponse } from "./IRewardsResponse"; + +export interface IDelegationDetailsResponse extends IResponse { + /** + * The outputs data. + */ + outputs?: IRewardsResponse[]; +} diff --git a/client/src/models/api/nova/IRewardsResponse.ts b/client/src/models/api/nova/IRewardsResponse.ts index 2ad5deab9..ebd942372 100644 --- a/client/src/models/api/nova/IRewardsResponse.ts +++ b/client/src/models/api/nova/IRewardsResponse.ts @@ -1,6 +1,6 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import { ManaRewardsResponse } from "@iota/sdk-wasm-nova/web"; +import { ManaRewardsResponse, OutputResponse } from "@iota/sdk-wasm-nova/web"; import { IResponse } from "./IResponse"; export interface IRewardsResponse extends IResponse { @@ -13,4 +13,9 @@ export interface IRewardsResponse extends IResponse { * The output mana rewards. */ manaRewards?: ManaRewardsResponse; + + /** + * The output data. + */ + output?: OutputResponse; } diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index feecd7730..d9bd39ec9 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -38,6 +38,7 @@ import { ICongestionResponse } from "~/models/api/nova/ICongestionResponse"; import { IAccountValidatorDetailsRequest } from "~/models/api/nova/IAccountValidatorDetailsRequest"; import { IAccountValidatorDetailsResponse } from "~/models/api/nova/IAccountValidatorDetailsResponse"; import { ILatestSlotCommitmentResponse } from "~/models/api/nova/ILatestSlotCommitmentsResponse"; +import { IDelegationDetailsResponse } from "~/models/api/nova/IDelegationDetailsResponse"; /** * Class to handle api communications on nova. @@ -165,8 +166,8 @@ export class NovaApiClient extends ApiClient { * @param request The Address Delegation outputs request. * @returns The Address outputs response */ - public async delegationOutputsDetails(request: IAddressDetailsRequest): Promise { - return this.callApi( + public async delegationOutputsDetails(request: IAddressDetailsRequest): Promise { + return this.callApi( `nova/address/outputs/delegation/${request.network}/${request.address}`, "get", ); From 887df571d8655986be3f817517dfe261cdd966ee Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Thu, 29 Feb 2024 09:43:12 +0100 Subject: [PATCH 29/31] fix: sdk changes to address calculations + styling fix --- .../section/delegation/DelegationSection.scss | 37 +------------------ .../section/delegation/DelegationSection.tsx | 17 +++++---- client/src/scss/card.scss | 4 ++ 3 files changed, 14 insertions(+), 44 deletions(-) diff --git a/client/src/app/components/nova/address/section/delegation/DelegationSection.scss b/client/src/app/components/nova/address/section/delegation/DelegationSection.scss index 8dac0126f..97f48688d 100644 --- a/client/src/app/components/nova/address/section/delegation/DelegationSection.scss +++ b/client/src/app/components/nova/address/section/delegation/DelegationSection.scss @@ -52,45 +52,10 @@ } } -.delegation-cards { +.cards--delegation { display: none; @include tablet-down { display: block; - - .card--delegation { - margin-bottom: 48px; - - .field { - margin-bottom: 8px; - - .label { - color: $gray-6; - font-family: $inter; - letter-spacing: 0.5px; - - @include font-size(14px, 21px); - } - - .value { - @include font-size(14px, 21px); - - color: var(--body-color); - font-family: $metropolis; - font-weight: 700; - - .highlight { - color: var(--link-color); - @include font-size(14px); - - font-family: $ibm-plex-mono; - font-weight: normal; - letter-spacing: 0.02em; - line-height: 20px; - max-width: 200px; - } - } - } - } } } diff --git a/client/src/app/components/nova/address/section/delegation/DelegationSection.tsx b/client/src/app/components/nova/address/section/delegation/DelegationSection.tsx index a79d6620d..e8399c5bc 100644 --- a/client/src/app/components/nova/address/section/delegation/DelegationSection.tsx +++ b/client/src/app/components/nova/address/section/delegation/DelegationSection.tsx @@ -34,7 +34,7 @@ const DelegationSection: React.FC = ({ delegationDetails return (
-
+
Total amount
{totalAmount?.toString()}
@@ -56,8 +56,8 @@ const DelegationSection: React.FC = ({ delegationDetails {currentPage.map((delegation, k) => { - const validatorAddress = Utils.accountIdToBech32( - (delegation.output?.output as DelegationOutput).validatorId, + const validatorAddress = Utils.addressToBech32( + (delegation.output?.output as DelegationOutput).validatorAddress, bech32Hrp, ); @@ -78,14 +78,15 @@ const DelegationSection: React.FC = ({ delegationDetails {/* Only visible in mobile*/} -
+
{currentPage.map((delegation, k) => { - const validatorAddress = Utils.accountIdToBech32( - (delegation.output?.output as DelegationOutput).validatorId, + const validatorAddress = Utils.addressToBech32( + (delegation.output?.output as DelegationOutput).validatorAddress, bech32Hrp, ); + return ( -
+
Output Id
@@ -93,7 +94,7 @@ const DelegationSection: React.FC = ({ delegationDetails
-
Validator Id
+
Validator Address
diff --git a/client/src/scss/card.scss b/client/src/scss/card.scss index d848fa3f5..05397a1d7 100644 --- a/client/src/scss/card.scss +++ b/client/src/scss/card.scss @@ -438,4 +438,8 @@ height: auto; } } + + &--no-border { + border: none; + } } From 3ec0ef0c0a1b9e998b501d1ed10e9da296786352 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Fri, 1 Mar 2024 14:07:50 +0100 Subject: [PATCH 30/31] fix: delegation response types --- api/src/models/api/nova/IDelegationDetailsResponse.ts | 3 ++- api/src/models/api/nova/IRewardsResponse.ts | 7 +------ api/src/routes/nova/address/outputs/delegation/get.ts | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/api/src/models/api/nova/IDelegationDetailsResponse.ts b/api/src/models/api/nova/IDelegationDetailsResponse.ts index e6bf3bdfa..d3c3506a5 100644 --- a/api/src/models/api/nova/IDelegationDetailsResponse.ts +++ b/api/src/models/api/nova/IDelegationDetailsResponse.ts @@ -1,5 +1,6 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { OutputResponse } from "@iota/sdk-nova"; import { IResponse } from "./IResponse"; import { IRewardsResponse } from "./IRewardsResponse"; @@ -7,5 +8,5 @@ export interface IDelegationDetailsResponse extends IResponse { /** * The outputs data. */ - outputs?: IRewardsResponse[]; + outputs?: { output: OutputResponse & IRewardsResponse }[]; } diff --git a/api/src/models/api/nova/IRewardsResponse.ts b/api/src/models/api/nova/IRewardsResponse.ts index 26790fee3..587c6dc14 100644 --- a/api/src/models/api/nova/IRewardsResponse.ts +++ b/api/src/models/api/nova/IRewardsResponse.ts @@ -1,6 +1,6 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import { ManaRewardsResponse, OutputResponse } from "@iota/sdk-nova"; +import { ManaRewardsResponse } from "@iota/sdk-nova"; import { IResponse } from "./IResponse"; export interface IRewardsResponse extends IResponse { @@ -13,9 +13,4 @@ export interface IRewardsResponse extends IResponse { * The output mana rewards. */ manaRewards?: ManaRewardsResponse; - - /** - * The output data. - */ - output: OutputResponse; } diff --git a/api/src/routes/nova/address/outputs/delegation/get.ts b/api/src/routes/nova/address/outputs/delegation/get.ts index f1aba59de..59d6fe9b8 100644 --- a/api/src/routes/nova/address/outputs/delegation/get.ts +++ b/api/src/routes/nova/address/outputs/delegation/get.ts @@ -1,6 +1,6 @@ import { ServiceFactory } from "../../../../../factories/serviceFactory"; import { IAddressDetailsRequest } from "../../../../../models/api/nova/IAddressDetailsRequest"; -import { IAddressDetailsResponse } from "../../../../../models/api/nova/IAddressDetailsResponse"; +import { IDelegationDetailsResponse } from "../../../../../models/api/nova/IDelegationDetailsResponse"; import { IConfiguration } from "../../../../../models/configuration/IConfiguration"; import { NOVA } from "../../../../../models/db/protocolVersion"; import { NetworkService } from "../../../../../services/networkService"; @@ -13,7 +13,7 @@ import { ValidationHelper } from "../../../../../utils/validationHelper"; * @param request The request. * @returns The response. */ -export async function get(config: IConfiguration, request: IAddressDetailsRequest): Promise { +export async function get(config: IConfiguration, request: IAddressDetailsRequest): Promise { const networkService = ServiceFactory.get("network"); const networks = networkService.networkNames(); ValidationHelper.oneOf(request.network, networks, "network"); From 758aca474ea2942f61f9f23fb9432bbfc8904ef4 Mon Sep 17 00:00:00 2001 From: Branko Bosnic Date: Tue, 5 Mar 2024 09:49:24 +0100 Subject: [PATCH 31/31] fix: lint errors --- api/src/models/api/nova/IDelegationWithDetails.ts | 2 ++ client/src/scss/card.scss | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/models/api/nova/IDelegationWithDetails.ts b/api/src/models/api/nova/IDelegationWithDetails.ts index 94283092d..bb419d816 100644 --- a/api/src/models/api/nova/IDelegationWithDetails.ts +++ b/api/src/models/api/nova/IDelegationWithDetails.ts @@ -1,3 +1,5 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { OutputWithMetadataResponse } from "@iota/sdk-nova"; import { IRewardsResponse } from "./IRewardsResponse"; diff --git a/client/src/scss/card.scss b/client/src/scss/card.scss index 05397a1d7..13acd1590 100644 --- a/client/src/scss/card.scss +++ b/client/src/scss/card.scss @@ -440,6 +440,6 @@ } &--no-border { - border: none; + border: 0; } }