From 61ff44279d42267586460bab9c6181a12e9c6ac9 Mon Sep 17 00:00:00 2001 From: Bran <52735957+brancoder@users.noreply.github.com> Date: Sun, 18 Feb 2024 13:41:17 +0100 Subject: [PATCH] feat: Add Native tokens Tab to address pages (#1119) * feat: add output tab to address page * fix: add components to display native tokens * Remove unused code in AddressPageTabbedSections component * remove unused interfaces * Update address states and Add basic outputs API endpoint * Add eslint-disable for unsafe return in novaApiService.ts * Add Foundries tab to Accound address page (#1134) * fix: validation check in foundries endpoint * fix: component imports * fix: imports * fix: select first tab on address page --------- Co-authored-by: Mario --- .../models/api/nova/IAddressDetailsRequest.ts | 11 ++ .../api/nova/IAddressDetailsResponse.ts | 11 ++ .../api/nova/foundry/IFoundriesRequest.ts | 11 ++ .../api/nova/foundry/IFoundriesResponse.ts | 11 ++ .../api/nova/foundry/IFoundryRequest.ts | 11 ++ .../api/nova/foundry/IFoundryResponse.ts | 11 ++ api/src/routes.ts | 13 +++ api/src/routes/nova/account/foundries/get.ts | 30 +++++ .../routes/nova/address/outputs/basic/get.ts | 29 +++++ api/src/routes/nova/foundry/get.ts | 30 +++++ api/src/services/nova/novaApiService.ts | 99 +++++++++++++++- .../nova/address/AccountAddressView.tsx | 14 +-- .../nova/address/AnchorAddressView.tsx | 14 +-- .../nova/address/Ed25519AddressView.tsx | 16 +-- .../ImplicitAccountCreationAddressView.tsx | 14 +-- .../nova/address/NftAddressView.tsx | 14 +-- .../section/AddressPageTabbedSections.tsx | 83 ++++++++++++-- .../account/AccountFoundriesSection.scss | 14 +++ .../account/AccountFoundriesSection.tsx | 53 +++++++++ .../address/section/native-tokens/Asset.tsx | 107 +++++++++++++++++ .../section/native-tokens/AssetProps.tsx | 13 +++ .../section/native-tokens/AssetsTable.scss | 102 +++++++++++++++++ .../section/native-tokens/AssetsTable.tsx | 108 ++++++++++++++++++ .../nova/hooks/useAccountAddressState.ts | 28 ++++- .../hooks/useAccountControlledFoundries.ts | 70 ++++++++++++ .../nova/hooks/useAddressBasicOutputs.ts | 43 +++++++ .../nova/hooks/useAnchorAddressState.ts | 20 +++- .../nova/hooks/useEd25519AddressState.ts | 20 +++- .../helpers/nova/hooks/useFoundryDetails.ts | 48 ++++++++ .../useImplicitAccountCreationAddressState.ts | 20 +++- .../helpers/nova/hooks/useNftAddressState.ts | 20 +++- .../nova/address/IAddressDetailsRequest.ts | 11 ++ .../nova/address/IAddressDetailsResponse.ts | 9 ++ .../api/nova/foundry/IFoundriesRequest.ts | 11 ++ .../api/nova/foundry/IFoundriesResponse.ts | 9 ++ .../api/nova/foundry/IFoundryRequest.ts | 11 ++ .../api/nova/foundry/IFoundryResponse.ts | 9 ++ client/src/services/nova/novaApiClient.ts | 33 ++++++ 38 files changed, 1105 insertions(+), 76 deletions(-) create mode 100644 api/src/models/api/nova/IAddressDetailsRequest.ts create mode 100644 api/src/models/api/nova/IAddressDetailsResponse.ts 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/models/api/nova/foundry/IFoundryRequest.ts create mode 100644 api/src/models/api/nova/foundry/IFoundryResponse.ts create mode 100644 api/src/routes/nova/account/foundries/get.ts create mode 100644 api/src/routes/nova/address/outputs/basic/get.ts create mode 100644 api/src/routes/nova/foundry/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/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/useAccountControlledFoundries.ts create mode 100644 client/src/helpers/nova/hooks/useAddressBasicOutputs.ts create mode 100644 client/src/helpers/nova/hooks/useFoundryDetails.ts create mode 100644 client/src/models/api/nova/address/IAddressDetailsRequest.ts create mode 100644 client/src/models/api/nova/address/IAddressDetailsResponse.ts create mode 100644 client/src/models/api/nova/foundry/IFoundriesRequest.ts create mode 100644 client/src/models/api/nova/foundry/IFoundriesResponse.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/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/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/models/api/nova/foundry/IFoundryRequest.ts b/api/src/models/api/nova/foundry/IFoundryRequest.ts new file mode 100644 index 000000000..6c655435c --- /dev/null +++ b/api/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/api/src/models/api/nova/foundry/IFoundryResponse.ts b/api/src/models/api/nova/foundry/IFoundryResponse.ts new file mode 100644 index 000000000..9bd84790d --- /dev/null +++ b/api/src/models/api/nova/foundry/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 0180f7b98..4fbceb165 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -222,6 +222,13 @@ 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/address/outputs/basic/:network/:address", + method: "get", + folder: "nova/address/outputs/basic", + func: "get", + }, { path: "/nova/output/associated/:network/:address", method: "post", @@ -229,6 +236,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..d5f210bbc --- /dev/null +++ b/api/src/routes/nova/account/foundries/get.ts @@ -0,0 +1,30 @@ +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, "accountAddress"); + + 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/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 new file mode 100644 index 000000000..e27384a49 --- /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/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"; +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 f46e24d54..09d70ff27 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -1,9 +1,13 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import { Client } from "@iota/sdk-nova"; +/* eslint-disable @typescript-eslint/no-unsafe-return */ +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"; import { IAnchorDetailsResponse } from "../../models/api/nova/IAnchorDetailsResponse"; import { IBlockDetailsResponse } from "../../models/api/nova/IBlockDetailsResponse"; import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; @@ -153,6 +157,99 @@ 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. + * @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 }; + } + } 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..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"; @@ -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..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"; @@ -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..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; @@ -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..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 { @@ -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..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"; @@ -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 82ba54e4a..29410a48d 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -1,32 +1,69 @@ 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 { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; +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"; +import { AddressType } from "@iota/sdk-wasm-nova/web"; +import AccountFoundriesSection from "./account/AccountFoundriesSection"; enum DEFAULT_TABS { AssocOutputs = "Outputs", + NativeTokens = "Native Tokens", } -const buildDefaultTabsOptions = (associatedOutputCount: number) => ({ +enum ACCOUNT_TABS { + Foundries = "Foundries", +} + +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, + }, +}); + +const buildAccountAddressTabsOptions = (foundriesCount: number, isAccountFoundriesLoading: boolean) => ({ + [ACCOUNT_TABS.Foundries]: { + disabled: foundriesCount === 0, + hidden: foundriesCount === 0, + isLoading: isAccountFoundriesLoading, + infoContent: foundriesMessage, + }, }); 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, setTokensCount] = useState(0); - if (!addressDetails) { + if (!addressState.addressDetails) { return null; } + const { addressDetails, addressBasicOutputs } = addressState; const defaultSections = [ , + , ]; - const tabEnums = DEFAULT_TABS; - const defaultTabsOptions = buildDefaultTabsOptions(outputCount); - const tabOptions = defaultTabsOptions; - const tabbedSections = defaultSections; + const accountAddressSections = + addressDetails.type === AddressType.Account + ? [ + , + ] + : null; + let tabEnums = DEFAULT_TABS; + const defaultTabsOptions = buildDefaultTabsOptions(tokensCount, outputCount); + 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..d0fd20aaa --- /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 "~/app/components/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/app/components/nova/address/section/native-tokens/Asset.tsx b/client/src/app/components/nova/address/section/native-tokens/Asset.tsx new file mode 100644 index 000000000..426614915 --- /dev/null +++ b/client/src/app/components/nova/address/section/native-tokens/Asset.tsx @@ -0,0 +1,107 @@ +/* eslint-disable jsdoc/require-param */ +/* eslint-disable jsdoc/require-returns */ +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/nova/hooks/useFoundryDetails"; +import { useTokenRegistryNativeTokenCheck } from "~helpers/stardust/hooks/useTokenRegistryNativeTokenCheck"; +import Spinner from "~/app/components/Spinner"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; + +/** + * Component which will display an asset. + */ +const Asset: React.FC = ({ 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(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..34bf43384 --- /dev/null +++ b/client/src/app/components/nova/address/section/native-tokens/AssetProps.tsx @@ -0,0 +1,13 @@ +import { NativeToken } from "@iota/sdk-wasm-nova/web"; + +export interface AssetProps { + /** + * Token + */ + token: NativeToken; + + /** + * 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..011e35dcf --- /dev/null +++ b/client/src/app/components/nova/address/section/native-tokens/AssetsTable.tsx @@ -0,0 +1,108 @@ +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"; +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: NativeToken[] = []; + 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 = { + setTokensCount: undefined, +}; + +export default AssetsTable; diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index 76c649eb5..a92f679db 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -1,5 +1,5 @@ import { Reducer, useEffect, useReducer } from "react"; -import { AccountAddress, AccountOutput } from "@iota/sdk-wasm-nova/web"; +import { AccountAddress, AccountOutput, 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"; @@ -7,23 +7,33 @@ 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"; +import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; export interface IAccountAddressState { - accountAddressDetails: IAddressDetails | null; + addressDetails: IAddressDetails | null; accountOutput: AccountOutput | null; totalBalance: number | null; availableBalance: number | null; + addressBasicOutputs: OutputResponse[] | null; + foundries: string[] | null; isAccountDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; + isBasicOutputsLoading: boolean; + isFoundriesLoading: boolean; } const initialState = { - accountAddressDetails: null, + addressDetails: null, accountOutput: null, totalBalance: null, availableBalance: null, + addressBasicOutputs: null, + foundries: null, isAccountDetailsLoading: true, isAssociatedOutputsLoading: false, + isBasicOutputsLoading: false, + isFoundriesLoading: false, }; /** @@ -43,7 +53,9 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres ); const { accountOutput, isLoading: isAccountDetailsLoading } = useAccountDetails(network, address.accountId); - const { totalBalance, availableBalance } = useAddressBalance(network, state.accountAddressDetails, accountOutput); + 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; @@ -53,7 +65,7 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres setState({ ...initialState, - accountAddressDetails: addressDetails, + addressDetails, }); }, []); @@ -63,8 +75,12 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres isAccountDetailsLoading, totalBalance, availableBalance, + foundries, + addressBasicOutputs, + isBasicOutputsLoading, + isFoundriesLoading, }); - }, [accountOutput, totalBalance, availableBalance, isAccountDetailsLoading]); + }, [accountOutput, totalBalance, availableBalance, addressBasicOutputs, isAccountDetailsLoading, isBasicOutputsLoading]); return [state, setState]; }; 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/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/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/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/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/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 bd466c835..0ad378576 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -21,8 +21,14 @@ 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"; 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. @@ -100,6 +106,33 @@ 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 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. + * @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.