diff --git a/api/src/models/api/nova/IAccountRequest.ts b/api/src/models/api/nova/IAccountRequest.ts new file mode 100644 index 000000000..24fa3324a --- /dev/null +++ b/api/src/models/api/nova/IAccountRequest.ts @@ -0,0 +1,11 @@ +export interface IAccountRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the account details for. + */ + accountId: string; +} diff --git a/api/src/models/api/nova/IAccountResponse.ts b/api/src/models/api/nova/IAccountResponse.ts new file mode 100644 index 000000000..4db772845 --- /dev/null +++ b/api/src/models/api/nova/IAccountResponse.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 IAccountResponse extends IResponse { + /** + * The account details response. + */ + accountDetails?: OutputResponse; +} diff --git a/api/src/models/api/nova/IAssociationsResponse.ts b/api/src/models/api/nova/IAssociationsResponse.ts new file mode 100644 index 000000000..e64c711c7 --- /dev/null +++ b/api/src/models/api/nova/IAssociationsResponse.ts @@ -0,0 +1,44 @@ +import { IResponse } from "../IResponse"; + +export enum AssociationType { + BASIC_ADDRESS, + BASIC_STORAGE_RETURN, + BASIC_EXPIRATION_RETURN, + BASIC_SENDER, + ACCOUNT_ADDRESS, + ACCOUNT_ISSUER, + ACCOUNT_SENDER, + ACCOUNT_ID, + ANCHOR_ID, + ANCHOR_STATE_CONTROLLER, + ANCHOR_GOVERNOR, + ANCHOR_ISSUER, + ANCHOR_SENDER, + DELEGATION_ADDRESS, + DELEGATION_VALIDATOR, + FOUNDRY_ACCOUNT, + NFT_ADDRESS, + NFT_STORAGE_RETURN, + NFT_EXPIRATION_RETURN, + NFT_ISSUER, + NFT_SENDER, + NFT_ID, +} + +export interface IAssociation { + /** + * The association for the output ids. + */ + type: AssociationType; + /** + * The output ids for the association. + */ + outputIds: string[]; +} + +export interface IAssociationsResponse extends IResponse { + /** + * The associations to output ids. + */ + associations?: IAssociation[]; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index 2bb69c61d..08b59926b 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -203,6 +203,14 @@ export const routes: IRoute[] = [ }, // Nova { path: "/nova/output/:network/:outputId", method: "get", folder: "nova/output", func: "get" }, + { path: "/nova/account/:network/:accountId", method: "get", folder: "nova/account", func: "get" }, + { + path: "/nova/output/associated/:network/:address", + method: "post", + folder: "nova/output/associated", + func: "post", + dataBody: true, + }, { 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/get.ts b/api/src/routes/nova/account/get.ts new file mode 100644 index 000000000..a8a9c82fb --- /dev/null +++ b/api/src/routes/nova/account/get.ts @@ -0,0 +1,29 @@ +import { ServiceFactory } from "../../../factories/serviceFactory"; +import { IAccountRequest } from "../../../models/api/nova/IAccountRequest"; +import { IAccountResponse } from "../../../models/api/nova/IAccountResponse"; +import { IConfiguration } from "../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../models/db/protocolVersion"; +import { NetworkService } from "../../../services/networkService"; +import { NovaApi } from "../../../services/nova/novaApi"; +import { ValidationHelper } from "../../../utils/validationHelper"; + +/** + * Get account output details by Account id + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: IAccountRequest): 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 {}; + } + + return NovaApi.accountDetails(networkConfig, request.accountId); +} diff --git a/api/src/routes/nova/output/associated/post.ts b/api/src/routes/nova/output/associated/post.ts new file mode 100644 index 000000000..0fc9952a5 --- /dev/null +++ b/api/src/routes/nova/output/associated/post.ts @@ -0,0 +1,46 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { IAssociation, IAssociationsResponse } from "../../../../models/api/nova/IAssociationsResponse"; +import { IAssociationsRequest } from "../../../../models/api/stardust/IAssociationsRequest"; +import { IAssociationsRequestBody } from "../../../../models/api/stardust/IAssociationsRequestBody"; +import { IConfiguration } from "../../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../../models/db/protocolVersion"; +import { NetworkService } from "../../../../services/networkService"; +import { AssociatedOutputsHelper } from "../../../../utils/nova/associatedOutputsHelper"; +import { ValidationHelper } from "../../../../utils/validationHelper"; + +/** + * Find the associated outputs for the address. + * @param _ The configuration. + * @param request The request. + * @param body The request body + * @returns The response. + */ +export async function post( + _: IConfiguration, + request: IAssociationsRequest, + body: IAssociationsRequestBody, +): 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 helper = new AssociatedOutputsHelper(networkConfig, body.addressDetails); + await helper.fetch(); + const result = helper.associationToOutputIds; + + const associations: IAssociation[] = []; + for (const [type, outputIds] of result.entries()) { + associations.push({ type, outputIds: outputIds.reverse() }); + } + + return { + associations, + }; +} diff --git a/api/src/services/nova/novaApi.ts b/api/src/services/nova/novaApi.ts index fb83c927c..53ee46fb9 100644 --- a/api/src/services/nova/novaApi.ts +++ b/api/src/services/nova/novaApi.ts @@ -3,6 +3,7 @@ import { __ClientMethods__, OutputResponse, Client, Block, IBlockMetadata } from "@iota/sdk-nova"; import { ServiceFactory } from "../../factories/serviceFactory"; import logger from "../../logger"; +import { IAccountResponse } from "../../models/api/nova/IAccountResponse"; import { IBlockDetailsResponse } from "../../models/api/nova/IBlockDetailsResponse"; import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; @@ -71,6 +72,23 @@ export class NovaApi { return outputResponse ? { output: outputResponse } : { message: "Output not found" }; } + /** + * Get the account details. + * @param network The network to find the items on. + * @param accountId The accountId to get the details for. + * @returns The account details. + */ + public static async accountDetails(network: INetwork, accountId: string): Promise { + const accountOutputId = await this.tryFetchNodeThenPermanode(accountId, "accountOutputId", network); + + if (accountOutputId) { + const outputResponse = await this.outputDetails(network, accountOutputId); + return outputResponse.error ? { error: outputResponse.error } : { accountDetails: outputResponse.output }; + } + + return { message: "Account output not found" }; + } + /** * Generic helper function to try fetching from node client. * On failure (or not present), we try to fetch from permanode (if configured). diff --git a/api/src/utils/nova/associatedOutputsHelper.ts b/api/src/utils/nova/associatedOutputsHelper.ts new file mode 100644 index 000000000..688d26c6e --- /dev/null +++ b/api/src/utils/nova/associatedOutputsHelper.ts @@ -0,0 +1,294 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { + Client, + IOutputsResponse, + AddressType, + BasicOutputQueryParameters, + AccountOutputQueryParameters, + AnchorOutputQueryParameters, + DelegationOutputQueryParameters, + FoundryOutputQueryParameters, + NftOutputQueryParameters, +} from "@iota/sdk-nova"; +import { ServiceFactory } from "../../factories/serviceFactory"; +import { AssociationType } from "../../models/api/nova/IAssociationsResponse"; +import { IBech32AddressDetails } from "../../models/api/stardust/IBech32AddressDetails"; +import { INetwork } from "../../models/db/INetwork"; + +/** + * Helper class to fetch associated outputs of an address on stardust. + */ +export class AssociatedOutputsHelper { + public readonly associationToOutputIds: Map = new Map(); + + private readonly network: INetwork; + + private readonly addressDetails: IBech32AddressDetails; + + constructor(network: INetwork, addressDetails: IBech32AddressDetails) { + this.network = network; + this.addressDetails = addressDetails; + } + + public async fetch() { + const network = this.network.network; + const address = this.addressDetails.bech32; + + const client = ServiceFactory.get(`client-${network}`); + const promises: Promise[] = []; + + // BASIC OUTPUTS + + promises.push( + // Basic output -> address unlock condition + this.fetchAssociatedOutputIds( + async (query) => client.basicOutputIds(query), + { address }, + AssociationType.BASIC_ADDRESS, + ), + ); + + promises.push( + // Basic output -> storage return address + this.fetchAssociatedOutputIds( + async (query) => client.basicOutputIds(query), + { storageDepositReturnAddress: address }, + AssociationType.BASIC_STORAGE_RETURN, + ), + ); + + promises.push( + // Basic output -> expiration return address + this.fetchAssociatedOutputIds( + async (query) => client.basicOutputIds(query), + { expirationReturnAddress: address }, + AssociationType.BASIC_EXPIRATION_RETURN, + ), + ); + + promises.push( + // Basic output -> sender address + this.fetchAssociatedOutputIds( + async (query) => client.basicOutputIds(query), + { sender: address }, + AssociationType.BASIC_SENDER, + ), + ); + + // ACCOUNT OUTPUTS + + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (this.addressDetails.type === AddressType.Account && this.addressDetails.hex) { + const aliasId = this.addressDetails.hex; + promises.push( + // Alias id + this.fetchAssociatedOutputIds(async (query) => client.accountOutputId(query), aliasId, AssociationType.ACCOUNT_ID), + ); + } + + promises.push( + // Alias output -> address unlock condition + this.fetchAssociatedOutputIds( + async (query) => client.accountOutputIds(query), + { address }, + AssociationType.ACCOUNT_ADDRESS, + ), + ); + + promises.push( + // Alias output -> issuer address + this.fetchAssociatedOutputIds( + async (query) => client.accountOutputIds(query), + { issuer: address }, + AssociationType.ACCOUNT_ISSUER, + ), + ); + + promises.push( + // Alias output -> sender address + this.fetchAssociatedOutputIds( + async (query) => client.accountOutputIds(query), + { sender: address }, + AssociationType.ACCOUNT_SENDER, + ), + ); + + // ANCHOR OUTPUTS + + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (this.addressDetails.type === AddressType.Anchor && this.addressDetails.hex) { + const anchorId = this.addressDetails.hex; + promises.push( + // Alias id + this.fetchAssociatedOutputIds(async (query) => client.anchorOutputId(query), anchorId, AssociationType.ANCHOR_ID), + ); + } + + promises.push( + // Anchor output -> state controller address + this.fetchAssociatedOutputIds( + async (query) => client.anchorOutputIds(query), + { stateController: address }, + AssociationType.ANCHOR_STATE_CONTROLLER, + ), + ); + + promises.push( + // Anchor output -> governor address + this.fetchAssociatedOutputIds( + async (query) => client.anchorOutputIds(query), + { governor: address }, + AssociationType.ANCHOR_GOVERNOR, + ), + ); + + promises.push( + // Anchor output -> issuer address + this.fetchAssociatedOutputIds( + async (query) => client.anchorOutputIds(query), + { issuer: address }, + AssociationType.ANCHOR_ISSUER, + ), + ); + + promises.push( + // Anchor output -> sender address + this.fetchAssociatedOutputIds( + async (query) => client.anchorOutputIds(query), + { sender: address }, + AssociationType.ANCHOR_SENDER, + ), + ); + + // DELEGATION OUTPUTS + + promises.push( + // Delegation output -> address unlock condition + this.fetchAssociatedOutputIds( + async (query) => client.delegationOutputIds(query), + { address }, + AssociationType.DELEGATION_ADDRESS, + ), + ); + + promises.push( + // Delegation output -> validator + this.fetchAssociatedOutputIds( + async (query) => client.delegationOutputIds(query), + { validator: address }, + AssociationType.DELEGATION_VALIDATOR, + ), + ); + + // FOUNDRY OUTPUTS + + promises.push( + // Foundry output -> account address + this.fetchAssociatedOutputIds( + async (query) => client.foundryOutputIds(query), + { account: address }, + AssociationType.FOUNDRY_ACCOUNT, + ), + ); + + // NFS OUTPUTS + + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (this.addressDetails.type === AddressType.Nft && this.addressDetails.hex) { + const nftId = this.addressDetails.hex; + promises.push( + // Nft id + this.fetchAssociatedOutputIds(async (query) => client.nftOutputId(query), nftId, AssociationType.NFT_ID), + ); + } + + promises.push( + // Nft output -> address unlock condition + this.fetchAssociatedOutputIds( + async (query) => client.nftOutputIds(query), + { address }, + AssociationType.NFT_ADDRESS, + ), + ); + + promises.push( + // Nft output -> storage return address + this.fetchAssociatedOutputIds( + async (query) => client.nftOutputIds(query), + { storageDepositReturnAddress: address }, + AssociationType.NFT_STORAGE_RETURN, + ), + ); + + promises.push( + // Nft output -> expiration return address + this.fetchAssociatedOutputIds( + async (query) => client.nftOutputIds(query), + { expirationReturnAddress: address }, + AssociationType.NFT_EXPIRATION_RETURN, + ), + ); + + promises.push( + // Nft output -> issuer address + this.fetchAssociatedOutputIds( + async (query) => client.nftOutputIds(query), + { issuer: address }, + AssociationType.NFT_ISSUER, + ), + ); + + promises.push( + // Nft output -> sender address + this.fetchAssociatedOutputIds( + async (query) => client.nftOutputIds(query), + { sender: address }, + AssociationType.NFT_SENDER, + ), + ); + + await Promise.all(promises); + } + + /** + * Generic helper function for fetching associated outputs. + * @param fetch The function for the API call + * @param args The parameters to pass to the call + * @param association The association we are looking for. + */ + private async fetchAssociatedOutputIds( + fetch: (req: T) => Promise, + args: T, + association: AssociationType, + ): Promise { + const associationToOutputIds = this.associationToOutputIds; + let cursor: string; + + do { + try { + const response = typeof args === "string" ? await fetch(args) : await fetch({ ...args, cursor }); + + if (typeof response === "string") { + const outputIds = associationToOutputIds.get(association); + if (outputIds) { + associationToOutputIds.set(association, outputIds.concat([response])); + } else { + associationToOutputIds.set(association, [response]); + } + } else if (response.items.length > 0) { + const outputIds = associationToOutputIds.get(association); + if (outputIds) { + associationToOutputIds.set(association, outputIds.concat(response.items)); + } else { + associationToOutputIds.set(association, response.items); + } + + cursor = response.cursor; + } + } catch {} + } while (cursor); + } +} diff --git a/client/src/app/AppUtils.tsx b/client/src/app/AppUtils.tsx index e7b82d016..0f240c6a2 100644 --- a/client/src/app/AppUtils.tsx +++ b/client/src/app/AppUtils.tsx @@ -37,7 +37,7 @@ export const populateNetworkInfoNova = (networkName: string) => { }) ?? null; const setNetworkInfoNova = useNetworkInfoNova.getState().setNetworkInfo; setNetworkInfoNova({ - name: nodeInfo?.name ?? "", + name: networkName, tokenInfo: nodeInfo?.baseToken ?? {}, protocolVersion: protocolInfo?.parameters.version ?? -1, bech32Hrp: protocolInfo?.parameters.bech32Hrp ?? "", diff --git a/client/src/app/components/nova/FeaturesView.tsx b/client/src/app/components/nova/FeaturesView.tsx index f07c5485a..f0b8af04b 100644 --- a/client/src/app/components/nova/FeaturesView.tsx +++ b/client/src/app/components/nova/FeaturesView.tsx @@ -13,7 +13,7 @@ import { import { Ed25519BlockIssuerKey } from "@iota/sdk-wasm-nova/web/lib/types/block/output/block-issuer-key"; import classNames from "classnames"; import React, { useState } from "react"; -import AddressView from "./AddressView"; +import AddressView from "./address/AddressView"; import DropdownIcon from "~assets/dropdown-arrow.svg?react"; import DataToggle from "../DataToggle"; diff --git a/client/src/app/components/nova/Input.tsx b/client/src/app/components/nova/Input.tsx index 0b5a58600..1861a216c 100644 --- a/client/src/app/components/nova/Input.tsx +++ b/client/src/app/components/nova/Input.tsx @@ -2,14 +2,14 @@ /* eslint-disable jsdoc/require-returns */ import { Utils } from "@iota/sdk-wasm-nova/web"; import classNames from "classnames"; -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { useHistory, Link } from "react-router-dom"; import Bech32Address from "../stardust/address/Bech32Address"; +import { useNetworkInfoNova } from "~helpers/nova/networkInfo"; import OutputView from "./OutputView"; import DropdownIcon from "~assets/dropdown-arrow.svg?react"; import { formatAmount } from "~helpers/stardust/valueFormatHelper"; import { IInput } from "~models/api/nova/IInput"; -import NetworkContext from "../../context/NetworkContext"; interface InputProps { /** @@ -27,7 +27,7 @@ interface InputProps { */ const Input: React.FC = ({ input, network }) => { const history = useHistory(); - const { tokenInfo } = useContext(NetworkContext); + const { tokenInfo } = useNetworkInfoNova((s) => s.networkInfo); const [isExpanded, setIsExpanded] = useState(false); const [isFormattedBalance, setIsFormattedBalance] = useState(true); diff --git a/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx index 697bb71eb..d138f6deb 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -36,7 +36,7 @@ interface OutputViewProps { const OutputView: React.FC = ({ outputId, output, showCopyAmount, isPreExpanded, isLinksDisabled }) => { const [isExpanded, setIsExpanded] = React.useState(isPreExpanded ?? false); const [isFormattedBalance, setIsFormattedBalance] = React.useState(true); - const { name: networkName, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const { bech32Hrp, name: network } = useNetworkInfoNova((s) => s.networkInfo); const aliasOrNftBech32 = buildAddressForAliasOrNft(outputId, output, bech32Hrp); const outputIdTransactionPart = `${outputId.slice(0, 8)}....${outputId.slice(-8, -4)}`; @@ -63,7 +63,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun {outputIdIndexPart} ) : ( - + {outputIdTransactionPart} {outputIdIndexPart} @@ -97,7 +97,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun
@@ -111,7 +111,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun
@@ -125,7 +125,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun
diff --git a/client/src/app/components/nova/UnlockConditionView.tsx b/client/src/app/components/nova/UnlockConditionView.tsx index 52b16ead9..b02e34f06 100644 --- a/client/src/app/components/nova/UnlockConditionView.tsx +++ b/client/src/app/components/nova/UnlockConditionView.tsx @@ -12,7 +12,7 @@ import { import classNames from "classnames"; import React from "react"; import DropdownIcon from "~assets/dropdown-arrow.svg?react"; -import AddressView from "./AddressView"; +import AddressView from "./address/AddressView"; interface UnlockConditionViewProps { unlockCondition: UnlockCondition; diff --git a/client/src/app/components/nova/AddressView.tsx b/client/src/app/components/nova/address/AddressView.tsx similarity index 96% rename from client/src/app/components/nova/AddressView.tsx rename to client/src/app/components/nova/address/AddressView.tsx index 44f7e5f43..c62b02e7f 100644 --- a/client/src/app/components/nova/AddressView.tsx +++ b/client/src/app/components/nova/address/AddressView.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Address, AddressType } from "@iota/sdk-wasm-nova/web"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; -import TruncatedId from "../stardust/TruncatedId"; +import TruncatedId from "../../stardust/TruncatedId"; interface AddressViewProps { address: Address; diff --git a/client/src/app/components/nova/address/section/association/AssociatedOutputs.scss b/client/src/app/components/nova/address/section/association/AssociatedOutputs.scss new file mode 100644 index 000000000..101bc5462 --- /dev/null +++ b/client/src/app/components/nova/address/section/association/AssociatedOutputs.scss @@ -0,0 +1,170 @@ +@import "../../../../../../scss/fonts"; +@import "../../../../../../scss/mixins"; +@import "../../../../../../scss/media-queries"; +@import "../../../../../../scss/variables"; +@import "../../../../../../scss/themes"; + +.section.associated-outputs { + .section--header { + justify-content: space-between; + + .associated-heading { + font-size: 20px; + + @include phone-down { + font-size: 16px; + } + } + + .tabs-wrapper { + display: flex; + + button.tab { + background: var(--associated-outputs-tabs-bg); + color: $gray-6; + border: 1px solid $gray-4; + border-radius: 0; + border-right: 0; + + &.active { + background: var(--associated-outputs-tabs-active); + } + + &:focus { + box-shadow: none; + } + + &:first-child { + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + } + &:last-child { + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + border-right: 1px solid $gray-4; + } + + @include phone-down { + font-size: 12px; + padding: 3px 5px; + } + } + } + } + + // Mobile + .associated--cards { + display: none; + + @include tablet-down { + display: block; + + .card { + .card--content__output { + padding: 8px 8px 4px 4px; + } + + .field { + margin: 8px 0 0 8px; + margin-bottom: 8px; + + .label { + color: $gray-6; + font-family: $inter; + letter-spacing: 0.5px; + + @include font-size(14px, 21px); + } + + .value { + margin: 8px 0 0 4px; + @include font-size(14px, 21px); + + color: var(--body-color); + font-family: $metropolis; + font-weight: 700; + } + } + } + } + } + + .associated--cards { + .card { + .card--content__output { + .card-header--wrapper { + margin: 0px; + } + } + } + + .output--label { + display: flex; + + .dropdown--icon { + cursor: pointer; + svg { + transition: transform 0.25s ease; + path { + fill: var(--card-color); + } + } + &.opened > svg { + transform: rotate(90deg); + } + } + } + + tr, + .card { + .found-in--wrapper { + display: flex; + + @include desktop-down { + flex-direction: column; + } + } + } + + tr { + .found-in, + .date-created { + color: $gray-6; + } + + .date-created { + font-family: $ibm-plex-mono; + } + + .amount { + color: $mint-green-7; + @include font-size(16px, 21px); + font-weight: 700; + } + } + + .card { + .found-in, + .date-created { + .value { + color: $gray-6; + } + } + + .date-created .value { + font-family: $ibm-plex-mono; + } + + .amount .value { + color: $mint-green-7; + @include font-size(16px, 21px); + font-weight: 700; + } + } + + button.color { + font-family: $ibm-plex-mono; + color: var(--link-color); + } + } +} diff --git a/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx b/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx new file mode 100644 index 000000000..186b1f1ec --- /dev/null +++ b/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx @@ -0,0 +1,85 @@ +import classNames from "classnames"; +import React, { useEffect, useState } from "react"; +import { AssociatedOutputTab, buildAssociatedOutputsTabs, outputTypeToAssociations } from "./AssociatedOutputsUtils"; +import AssociationSection from "./AssociationSection"; +import { useAssociatedOutputs } from "~helpers/nova/hooks/useAssociatedOutputs"; +import { useNetworkInfoNova } from "~helpers/nova/networkInfo"; +import { IBech32AddressDetails } from "~models/api/IBech32AddressDetails"; +import { AssociationType, IAssociation } from "~models/api/nova/IAssociationsResponse"; +import "./AssociatedOutputs.scss"; + +interface AssociatedOutputsProps { + /** + * Address details + */ + readonly addressDetails: IBech32AddressDetails; + /** + * Callback setter to report the associated outputs count. + */ + readonly setOutputCount?: (count: number) => void; + /** + * Callback setter to report if the component is loading outputs. + */ + readonly setIsLoading?: (isLoading: boolean) => void; +} + +const AssociatedOutputs: React.FC = ({ addressDetails, setOutputCount, setIsLoading }) => { + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const [currentTab, setCurrentTab] = useState("Basic"); + const [associations, isLoading] = useAssociatedOutputs(network, addressDetails, setOutputCount); + const [tabsToRender, setTabsToRender] = useState([]); + + useEffect(() => { + if (setIsLoading) { + setIsLoading(isLoading); + } + }, [isLoading]); + + useEffect(() => { + const tabs = buildAssociatedOutputsTabs(associations); + setTabsToRender(tabs); + if (tabs.length > 0) { + setCurrentTab(tabs[0]); + } + }, [associations]); + + const associationTypesToRender: AssociationType[] | undefined = outputTypeToAssociations.get(currentTab); + + return associations.length === 0 ? null : ( +
+
+
+ {tabsToRender.map((tab, idx) => ( + + ))} +
+
+ {associationTypesToRender?.map((associationType, idx) => { + const targetAssociation: IAssociation | undefined = associations.find( + (association) => association.type === associationType, + ); + return ( + + ); + })} +
+ ); +}; + +AssociatedOutputs.defaultProps = { + setIsLoading: undefined, + setOutputCount: undefined, +}; + +export default AssociatedOutputs; diff --git a/client/src/app/components/nova/address/section/association/AssociatedOutputsUtils.ts b/client/src/app/components/nova/address/section/association/AssociatedOutputsUtils.ts new file mode 100644 index 000000000..dfdbbdcaa --- /dev/null +++ b/client/src/app/components/nova/address/section/association/AssociatedOutputsUtils.ts @@ -0,0 +1,92 @@ +import { AssociationType, IAssociation } from "~models/api/nova/IAssociationsResponse"; + +export type AssociatedOutputTab = "Basic" | "Account" | "Anchor" | "Delegation" | "Foundry" | "NFT"; + +export const outputTypeToAssociations: Map = new Map([ + [ + "Basic", + [ + AssociationType.BASIC_ADDRESS, + AssociationType.BASIC_SENDER, + AssociationType.BASIC_EXPIRATION_RETURN, + AssociationType.BASIC_STORAGE_RETURN, + ], + ], + [ + "Account", + [AssociationType.ACCOUNT_ID, AssociationType.ACCOUNT_ADDRESS, AssociationType.ACCOUNT_ISSUER, AssociationType.ACCOUNT_SENDER], + ], + [ + "Anchor", + [ + AssociationType.ANCHOR_ID, + AssociationType.ANCHOR_STATE_CONTROLLER, + AssociationType.ANCHOR_GOVERNOR, + AssociationType.ANCHOR_ISSUER, + AssociationType.ANCHOR_SENDER, + ], + ], + ["Delegation", [AssociationType.DELEGATION_ADDRESS, AssociationType.DELEGATION_VALIDATOR]], + ["Foundry", [AssociationType.FOUNDRY_ACCOUNT]], + [ + "NFT", + [ + AssociationType.NFT_ID, + AssociationType.NFT_ADDRESS, + AssociationType.NFT_STORAGE_RETURN, + AssociationType.NFT_EXPIRATION_RETURN, + AssociationType.NFT_ISSUER, + AssociationType.NFT_SENDER, + ], + ], +]); + +export const ASSOCIATION_TYPE_TO_LABEL = { + [AssociationType.BASIC_ADDRESS]: "Address Unlock Condition", + [AssociationType.BASIC_STORAGE_RETURN]: "Storage Deposit Return Unlock Condition", + [AssociationType.BASIC_EXPIRATION_RETURN]: "Expiration Return Unlock Condtition", + [AssociationType.BASIC_SENDER]: "Sender Feature", + [AssociationType.ACCOUNT_ID]: "Account Id", + [AssociationType.ACCOUNT_ADDRESS]: "Address Unlock Condition", + [AssociationType.ACCOUNT_ISSUER]: "Issuer Feature", + [AssociationType.ACCOUNT_SENDER]: "Sender Feature", + [AssociationType.ANCHOR_ID]: "Anchor Id", + [AssociationType.ANCHOR_STATE_CONTROLLER]: "Anchor State Controller Address Unlock Condition", + [AssociationType.ANCHOR_GOVERNOR]: "Ancor Governor Address Unlock Condition", + [AssociationType.ANCHOR_ISSUER]: "Issuer Feature", + [AssociationType.ANCHOR_SENDER]: "Sender Feature", + [AssociationType.DELEGATION_ADDRESS]: "Address Unlock Condition", + [AssociationType.DELEGATION_VALIDATOR]: "Validator Address", + [AssociationType.FOUNDRY_ACCOUNT]: "Controlling Account", + [AssociationType.NFT_ID]: "Nft Id", + [AssociationType.NFT_ADDRESS]: "Address Unlock Condition", + [AssociationType.NFT_STORAGE_RETURN]: "Storage Deposit Return Unlock Condition", + [AssociationType.NFT_EXPIRATION_RETURN]: "Expiration Return Unlock Condtition", + [AssociationType.NFT_ISSUER]: "Issuer Feature", + [AssociationType.NFT_SENDER]: "Sender Feature", +}; + +export const buildAssociatedOutputsTabs = (associations: IAssociation[]): AssociatedOutputTab[] => { + const tabs: AssociatedOutputTab[] = []; + if (associations.length > 0) { + if (associations.some((association) => AssociationType[association.type].startsWith("BASIC"))) { + tabs.push("Basic"); + } + if (associations.some((association) => AssociationType[association.type].startsWith("ACCOUNT"))) { + tabs.push("Account"); + } + if (associations.some((association) => AssociationType[association.type].startsWith("ANCHOR"))) { + tabs.push("Anchor"); + } + if (associations.some((association) => AssociationType[association.type].startsWith("DELEGATION"))) { + tabs.push("Delegation"); + } + if (associations.some((association) => AssociationType[association.type].startsWith("FOUNDRY"))) { + tabs.push("Foundry"); + } + if (associations.some((association) => AssociationType[association.type].startsWith("NFT"))) { + tabs.push("NFT"); + } + } + return tabs; +}; diff --git a/client/src/app/components/nova/address/section/association/AssociationSection.scss b/client/src/app/components/nova/address/section/association/AssociationSection.scss new file mode 100644 index 000000000..70879a257 --- /dev/null +++ b/client/src/app/components/nova/address/section/association/AssociationSection.scss @@ -0,0 +1,173 @@ +@import "../../../../../../scss/fonts"; +@import "../../../../../../scss/mixins"; +@import "../../../../../../scss/media-queries"; +@import "../../../../../../scss/variables"; +@import "../../../../../../scss/themes"; + +.section.association-section { + padding: 20px 8px; + + .association-section--header { + height: 40px; + + .association-label { + color: var(--body-color); + } + } + + .association-section--table { + width: 100%; + margin-top: 16px; + + tbody { + margin-top: 12px; + } + + @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 { + border: none; + color: var(--body-color); + padding: 12px 4px; + &:first-child { + padding-left: 0px; + } + + &.association__output { + display: flex; + color: var(--link-color); + font-family: $ibm-plex-mono; + text-align: left; + max-width: 200px; + } + + &.date-created { + @include font-size(14px); + font-family: $ibm-plex-mono; + color: #485776; + text-align: left; + padding-left: 0px; + } + + &.amount { + color: var(--amount-color); + @include font-size(16px, 21px); + font-weight: 700; + text-align: left; + padding-left: 0px; + } + } + } + } + + .association-section--cards { + display: none; + margin-top: 16px; + + @include tablet-down { + display: block; + + .card { + .field { + margin: 8px 0 0 8px; + 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); + font-weight: 700; + } + + .highlight { + color: var(--link-color); + font-family: $ibm-plex-mono; + max-width: 200px; + } + + .date-created { + @include font-size(14px); + font-family: $ibm-plex-mono; + color: #485776; + } + + .amount { + color: var(--body-color); + font-family: $inter; + font-size: 0.875rem; + letter-spacing: 0.5px; + } + } + } + } + } + + .dropdown { + cursor: pointer; + + svg { + transition: transform 0.25s ease; + + path { + fill: var(--card-color); + } + } + + &.opened > svg { + transform: rotate(90deg); + } + } + + .row { + h3 { + color: #000; + } + } + + .association-section--pagination { + margin-top: 12px; + } + + .load-more--button { + margin: 24px 0 8px 0; + align-self: center; + font-family: $metropolis; + width: fit-content; + padding: 4px 8px; + cursor: pointer; + + &:hover { + button { + color: var(--link-highlight); + } + } + + button { + padding: 6px; + color: var(--header-icon-color); + } + } +} diff --git a/client/src/app/components/nova/address/section/association/AssociationSection.tsx b/client/src/app/components/nova/address/section/association/AssociationSection.tsx new file mode 100644 index 000000000..104a6a7d9 --- /dev/null +++ b/client/src/app/components/nova/address/section/association/AssociationSection.tsx @@ -0,0 +1,152 @@ +import classNames from "classnames"; +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { ASSOCIATION_TYPE_TO_LABEL } from "./AssociatedOutputsUtils"; +import DropdownIcon from "~assets/dropdown-arrow.svg?react"; +import { useOutputsDetails } from "~helpers/nova/hooks/useOutputsDetails"; +import { formatAmount } from "~helpers/stardust/valueFormatHelper"; +import { AssociationType } from "~models/api/nova/IAssociationsResponse"; +import Spinner from "../../../../Spinner"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import "./AssociationSection.scss"; + +interface IAssociatedSectionProps { + readonly association: AssociationType; + readonly outputIds: string[] | undefined; +} + +interface IOutputTableItem { + outputId: string; + amount: string; +} + +const PAGE_SIZE = 10; + +const AssociationSection: React.FC = ({ association, outputIds }) => { + const { tokenInfo, name: network } = useNetworkInfoNova((s) => s.networkInfo); + const [isExpanded, setIsExpanded] = useState(false); + const [isFormatBalance, setIsFormatBalance] = useState(false); + const [loadMoreCounter, setLoadMoreCounter] = useState(); + const [sliceToLoad, setSliceToLoad] = useState([]); + const [outputTableItems, setOutputTableItems] = useState([]); + const [outputsDetails, isLoading] = useOutputsDetails(network, sliceToLoad); + + useEffect(() => { + const loadedOutputItems: IOutputTableItem[] = [...outputTableItems]; + + for (const details of outputsDetails) { + const { output, metadata } = details.outputDetails; + const outputId = details.outputId; + + if (output && metadata) { + const amount = output.amount; + loadedOutputItems.push({ outputId, amount }); + } + } + setOutputTableItems(loadedOutputItems); + }, [outputsDetails]); + + useEffect(() => { + if (outputIds && loadMoreCounter !== undefined) { + const from = loadMoreCounter * PAGE_SIZE; + const to = from + PAGE_SIZE; + setSliceToLoad(outputIds.slice(from, to)); + } + }, [outputIds, loadMoreCounter]); + + const onExpandSection = () => { + setIsExpanded(!isExpanded); + if (loadMoreCounter === undefined) { + setLoadMoreCounter(0); + } + }; + + const onLoadMore = () => { + setLoadMoreCounter(loadMoreCounter === undefined ? 0 : loadMoreCounter + 1); + }; + + const count = outputIds?.length; + + return count ? ( +
+
+
+ +
+

+ {ASSOCIATION_TYPE_TO_LABEL[association]} ({count}) +

+ {isExpanded && isLoading && ( +
+ +
+ )} +
+ {!isExpanded || outputTableItems.length === 0 ? null : ( + + + + + + + + + + {outputTableItems.map((details, idx) => { + const { outputId, amount } = details; + + return ( + + + + + ); + })} + +
OUTPUT IDAMOUNT
+ + + setIsFormatBalance(!isFormatBalance)} className="pointer margin-r-5"> + {formatAmount(Number(amount), tokenInfo, isFormatBalance)} + +
+ +
+ {outputTableItems.map((details, idx) => { + const { outputId, amount } = details; + + return ( +
+
+
Output Id
+ + + +
+
+
Amount
+
+ setIsFormatBalance(!isFormatBalance)} className="pointer margin-r-5"> + {formatAmount(Number(amount), tokenInfo, isFormatBalance)} + +
+
+
+ ); + })} +
+ {outputTableItems.length < count && ( +
+ +
+ )} +
+ )} +
+ ) : null; +}; + +export default AssociationSection; diff --git a/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx b/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx index 553ada80d..f22a9075b 100644 --- a/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx +++ b/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx @@ -3,6 +3,7 @@ import React from "react"; import Modal from "~/app/components/Modal"; import Unlocks from "~/app/components/nova/Unlocks"; import OutputView from "~/app/components/nova/OutputView"; +import { useNetworkInfoNova } from "~helpers/nova/networkInfo"; import transactionPayloadMessage from "~assets/modals/stardust/block/transaction-payload.json"; import { IInput } from "~/models/api/nova/IInput"; import Input from "~/app/components/nova/Input"; @@ -14,7 +15,8 @@ interface SignedTransactionPayloadProps { } const SignedTransactionPayload: React.FC = ({ payload, inputs, header }) => { - const { networkId, outputs } = payload.transaction; + const { outputs } = payload.transaction; + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); const transactionId = Utils.transactionId(payload); return ( @@ -36,7 +38,7 @@ const SignedTransactionPayload: React.FC = ({ pay
{inputs.map((input, idx) => ( - + ))}
diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 75907348c..f22d4a61a 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -26,6 +26,7 @@ import { TransactionRouteProps as LegacyTransactionRouteProps } from "./routes/l import LegacyVisualizer from "./routes/legacy/Visualizer"; import { SearchRouteProps } from "./routes/SearchRouteProps"; import StardustAddressPage from "./routes/stardust/AddressPage"; +import NovaAddressPage from "./routes/nova/AddressPage"; import StardustBlock from "./routes/stardust/Block"; import StardustFoundry from "./routes/stardust/Foundry"; import { Landing as StardustLanding } from "./routes/stardust/landing/Landing"; @@ -174,6 +175,7 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom ]; const novaRoutes = [ + , , , , diff --git a/client/src/app/routes/nova/AddressPage.scss b/client/src/app/routes/nova/AddressPage.scss new file mode 100644 index 000000000..1dfa7b72a --- /dev/null +++ b/client/src/app/routes/nova/AddressPage.scss @@ -0,0 +1,283 @@ +@import "../../../scss/fonts"; +@import "../../../scss/mixins"; +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; + +.address-page { + display: flex; + flex-direction: column; + + .addr--header { + display: flex; + align-items: center; + + .addr--header__switch { + display: flex; + align-items: center; + + & > span { + @include font-size(12px, 18px); + + margin-right: 16px; + color: $gray-6; + font-family: $inter; + font-weight: 500; + } + + .switch { + display: inline-block; + position: relative; + width: 32px; + height: 20px; + } + + .switch input { + width: 0; + height: 0; + opacity: 0; + } + + .slider { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + transition: 0.4s; + background-color: $gray-4; + cursor: pointer; + } + + .slider::before { + content: ""; + position: absolute; + bottom: 2.5px; + left: 2.5px; + width: 15px; + height: 15px; + transition: 0.4s; + background-color: white; + } + + input:checked + .slider { + background-color: $green-6; + } + + input:focus + .slider { + box-shadow: 0 0 1px $green-6; + } + + input:checked + .slider::before { + transform: translateX(12px); + } + + .slider.round { + border-radius: 16px; + } + + .slider.round::before { + border-radius: 50%; + } + } + } + + .wrapper { + display: flex; + justify-content: center; + + .inner { + display: flex; + flex: 1; + flex-direction: column; + max-width: $desktop-width; + margin: 40px 20px; + + @include desktop-down { + max-width: 100%; + padding-right: 20px; + padding-left: 20px; + + > .row { + flex-direction: column; + } + } + + .cards { + flex: 1; + margin-right: 24px; + + @include desktop-down { + flex: unset; + width: 100%; + margin-right: 0; + } + + .value-buttons { + .col { + flex: 1; + width: auto; + } + } + + @include desktop-down { + .value-buttons { + flex-direction: column; + + .col { + flex: unset; + width: 100%; + } + + .col + .col { + margin-top: 23px; + margin-left: 0; + } + } + } + + .col + .col { + margin-left: 23px; + + @include tablet-down { + margin-left: 0; + } + } + } + + .card + .card { + margin-top: 23px; + } + } + } + + .asset-summary { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 24px; + + .section--assets { + background: var(--card-body); + border-radius: 6px; + width: 469px; + height: 85px; + + .inner--asset { + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 30px; + + .assets { + display: flex; + flex-direction: column; + } + } + + .svg-navigation { + width: 16px; + height: 16px; + margin-top: 6px; + } + + @include tablet-down { + width: 100%; + } + } + + .section--NFT { + background: var(--card-body); + border-radius: 6px; + width: 469px; + height: 85px; + margin-left: 20px; + + .inner--asset { + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 30px; + + .assets { + display: flex; + flex-direction: column; + } + } + + .svg-navigation { + width: 16px; + height: 16px; + margin-top: 6px; + } + + @include tablet-down { + margin-top: 20px; + width: 100%; + margin-left: 0px; + } + } + + @include tablet-down { + flex-direction: column; + } + } + + .no-border { + border-bottom: none; + } + + .transaction--section { + @include tablet-down { + .section--header { + flex-direction: column; + align-items: flex-start; + } + } + } + + .general-content { + padding-bottom: 26px; + + @include phone-down { + flex-direction: column; + } + } + + .qr-content { + @include tablet-down { + align-self: flex-end; + } + + @include phone-down { + align-self: center; + } + } + + .feature-block { + .card--label { + @include font-size(12px); + + display: flex; + align-items: center; + height: 32px; + color: var(--card-color); + font-family: $inter; + font-weight: 500; + letter-spacing: 0.5px; + white-space: nowrap; + } + + .card--value { + @include font-size(14px, 20px); + margin-bottom: 12px; + color: var(--body-color); + font-family: $ibm-plex-mono; + word-break: break-all; + + a, + button { + color: var(--link-color); + } + } + } +} diff --git a/client/src/app/routes/nova/AddressPage.tsx b/client/src/app/routes/nova/AddressPage.tsx new file mode 100644 index 000000000..fd40a1a12 --- /dev/null +++ b/client/src/app/routes/nova/AddressPage.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { RouteComponentProps } from "react-router-dom"; +import Modal from "~/app/components/Modal"; +import NotFound from "~/app/components/NotFound"; +import AssociatedOutputs from "~/app/components/nova/address/section/association/AssociatedOutputs"; +import Spinner from "~/app/components/Spinner"; +import Bech32Address from "~/app/components/stardust/address/Bech32Address"; +import { useAddressPageState } from "~/helpers/nova/hooks/useAddressPageState"; +import addressMainHeaderInfo from "~assets/modals/stardust/address/main-header.json"; +import { AddressRouteProps } from "../AddressRouteProps"; +import "./AddressPage.scss"; + +const AddressPage: React.FC> = ({ + match: { + params: { address }, + }, +}) => { + const [state] = useAddressPageState(); + const { bech32AddressDetails, isAccountDetailsLoading } = state; + + if (!bech32AddressDetails) { + renderAddressNotFound(address); + } + + const isPageLoading = isAccountDetailsLoading; + + return ( +
+
+ {bech32AddressDetails && ( +
+
+
+

{bech32AddressDetails.typeLabel?.replace("Ed25519", "Address")}

+
+ {isPageLoading && } +
+
+
+
+

General

+
+
+
+
+ +
+
+
+
+
+

Associated Outputs

+
+ +
+
+ )} +
+
+ ); +}; + +const renderAddressNotFound = (address: string) => ( +
+
+
+
+
+

Address

+ +
+
+ +
+
+
+); + +export default AddressPage; diff --git a/client/src/app/routes/nova/Block.tsx b/client/src/app/routes/nova/Block.tsx index f22b92ad5..c1beac903 100644 --- a/client/src/app/routes/nova/Block.tsx +++ b/client/src/app/routes/nova/Block.tsx @@ -20,6 +20,7 @@ import taggedDataPayloadInfo from "~assets/modals/stardust/block/tagged-data-pay import transactionPayloadInfo from "~assets/modals/stardust/block/transaction-payload.json"; import { useBlockMetadata } from "~/helpers/nova/hooks/useBlockMetadata"; import TransactionMetadataSection from "~/app/components/nova/block/section/TransactionMetadataSection"; + export interface BlockProps { /** * The network to lookup. @@ -33,7 +34,6 @@ export interface BlockProps { } const Block: React.FC> = ({ - history, match: { params: { network, blockId }, }, diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index 734772d28..918ff3c8a 100644 --- a/client/src/app/routes/nova/OutputPage.tsx +++ b/client/src/app/routes/nova/OutputPage.tsx @@ -60,7 +60,13 @@ const OutputPage: React.FC> = ({
- +
diff --git a/client/src/helpers/nova/hooks/useAccountDetails.ts b/client/src/helpers/nova/hooks/useAccountDetails.ts new file mode 100644 index 000000000..8c8b081dd --- /dev/null +++ b/client/src/helpers/nova/hooks/useAccountDetails.ts @@ -0,0 +1,48 @@ +import { AccountOutput } from "@iota/sdk-wasm-nova/web"; +import { useEffect, useState } from "react"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { useIsMounted } from "~/helpers/hooks/useIsMounted"; +import { HexHelper } from "~/helpers/stardust/hexHelper"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +/** + * Fetch account output details + * @param network The Network in context + * @param accountID The account id + * @returns The output response and loading bool. + */ +export function useAccountDetails(network: string, accountId: string | null): { accountOutput: AccountOutput | null; isLoading: boolean } { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [accountOutput, setAccountOutput] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (accountId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .accountDetails({ + network, + accountId: HexHelper.addPrefix(accountId), + }) + .then((response) => { + if (!response?.error && isMounted) { + const output = response.accountDetails?.output as AccountOutput; + + setAccountOutput(output); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, accountId]); + + return { accountOutput, isLoading }; +} diff --git a/client/src/helpers/nova/hooks/useAddressPageState.ts b/client/src/helpers/nova/hooks/useAddressPageState.ts new file mode 100644 index 000000000..0f76327c8 --- /dev/null +++ b/client/src/helpers/nova/hooks/useAddressPageState.ts @@ -0,0 +1,75 @@ +import { Reducer, useEffect, useReducer } from "react"; +import { AccountOutput, AddressType } from "@iota/sdk-wasm-nova/web"; +import { IBech32AddressDetails } from "~/models/api/IBech32AddressDetails"; +import { useAccountDetails } from "./useAccountDetails"; +import { useLocation, useParams } from "react-router-dom"; +import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; +import { useNetworkInfoNova } from "../networkInfo"; +import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; +import { scrollToTop } from "~/helpers/pageUtils"; +import { Bech32Helper } from "@iota/iota.js"; + +export interface IAddressState { + bech32AddressDetails: IBech32AddressDetails | null; + accountOutput: AccountOutput | null; + isAccountDetailsLoading: boolean; +} + +const initialState = { + bech32AddressDetails: null, + accountOutput: null, + isAccountDetailsLoading: true, +}; + +/** + * Route Location Props + */ +interface IAddressPageLocationProps { + addressDetails: IBech32AddressDetails; +} + +export const useAddressPageState = (): [IAddressState, React.Dispatch>] => { + const location = useLocation(); + const { network, address: addressFromPath } = useParams(); + const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const [state, setState] = useReducer>>( + (currentState, newState) => ({ ...currentState, ...newState }), + initialState, + ); + + const addressHex: string | null = state.bech32AddressDetails?.hex ?? null; + const addressType: number | null = state.bech32AddressDetails?.type ?? null; + + const { accountOutput, isLoading: isAccountDetailsLoading } = useAccountDetails( + network, + addressType === AddressType.Account ? addressHex : null, + ); + + useEffect(() => { + const locationState = location.state as IAddressPageLocationProps; + const { addressDetails } = locationState?.addressDetails + ? locationState + : { addressDetails: Bech32AddressHelper.buildAddress(bech32Hrp, addressFromPath) }; + + const isBech32 = Bech32Helper.matches(addressFromPath, bech32Hrp); + + if (isBech32) { + scrollToTop(); + setState({ + ...initialState, + bech32AddressDetails: addressDetails, + }); + } else { + setState(initialState); + } + }, [addressFromPath]); + + useEffect(() => { + setState({ + accountOutput, + isAccountDetailsLoading, + }); + }, [accountOutput, isAccountDetailsLoading]); + + return [state, setState]; +}; diff --git a/client/src/helpers/nova/hooks/useAssociatedOutputs.ts b/client/src/helpers/nova/hooks/useAssociatedOutputs.ts new file mode 100644 index 000000000..5eaf9d6db --- /dev/null +++ b/client/src/helpers/nova/hooks/useAssociatedOutputs.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { useIsMounted } from "~/helpers/hooks/useIsMounted"; +import { IBech32AddressDetails } from "~/models/api/IBech32AddressDetails"; +import { IAssociation } from "~/models/api/nova/IAssociationsResponse"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +/** + * Fetch Address associated outputs. + * @param network The Network in context. + * @param addressDetails The address details object. + * @param setOutputCount The callback setter for association outputs count. + * @returns The associations and isLoading boolean. + */ +export function useAssociatedOutputs( + network: string, + addressDetails: IBech32AddressDetails, + setOutputCount?: (count: number) => void, +): [IAssociation[], boolean] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [associations, setAssociations] = useState([]); + const [isAssociationsLoading, setIsAssociationsLoading] = useState(true); + + useEffect(() => { + setIsAssociationsLoading(true); + // eslint-disable-next-line no-void + void (async () => { + apiClient + .associatedOutputs({ network, addressDetails }) + .then((response) => { + if (response?.associations && isMounted) { + setAssociations(response.associations); + + if (setOutputCount) { + const outputsCount = response.associations + .flatMap((association) => association.outputIds.length) + .reduce((acc, next) => acc + next, 0); + setOutputCount(outputsCount); + } + } + }) + .finally(() => { + setIsAssociationsLoading(false); + }); + })(); + }, [network, addressDetails]); + + return [associations, isAssociationsLoading]; +} diff --git a/client/src/helpers/nova/hooks/useOutputsDetails.ts b/client/src/helpers/nova/hooks/useOutputsDetails.ts new file mode 100644 index 000000000..44bd198bd --- /dev/null +++ b/client/src/helpers/nova/hooks/useOutputsDetails.ts @@ -0,0 +1,77 @@ +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 { HexHelper } from "~helpers/stardust/hexHelper"; + +interface IOutputDetails { + outputDetails: OutputResponse; + outputId: string; +} + +/** + * Fetch outputs details + * @param network The Network in context + * @param outputIds The output ids + * @returns The outputs responses, loading bool and an error message. + */ +export function useOutputsDetails(network: string, outputIds: string[] | null): [IOutputDetails[], boolean, string?] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [outputs, setOutputs] = useState([]); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (outputIds) { + const promises: Promise[] = []; + const items: IOutputDetails[] = []; + + for (const outputId of outputIds) { + const promise = apiClient + .outputDetails({ + network, + outputId: HexHelper.addPrefix(outputId), + }) + .then((response) => { + const details = response.output; + if (!response?.error && details?.output && details?.metadata) { + const fetchedOutputDetails = { + output: details.output, + metadata: details.metadata, + }; + const item: IOutputDetails = { + outputDetails: fetchedOutputDetails, + outputId, + }; + items.push(item); + } else { + setError(response.error); + } + }) + .catch((e) => console.log(e)); + + promises.push(promise); + } + + Promise.allSettled(promises) + .then((_) => { + if (isMounted) { + setOutputs(items); + } + }) + .catch((_) => { + setError("Failed loading output details!"); + }) + .finally(() => { + setIsLoading(false); + }); + } else { + setIsLoading(false); + } + }, [network, outputIds]); + return [outputs, isLoading, error]; +} diff --git a/client/src/models/api/nova/IAccountRequest.ts b/client/src/models/api/nova/IAccountRequest.ts new file mode 100644 index 000000000..24fa3324a --- /dev/null +++ b/client/src/models/api/nova/IAccountRequest.ts @@ -0,0 +1,11 @@ +export interface IAccountRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the account details for. + */ + accountId: string; +} diff --git a/client/src/models/api/nova/IAccountResponse.ts b/client/src/models/api/nova/IAccountResponse.ts new file mode 100644 index 000000000..cc8f27543 --- /dev/null +++ b/client/src/models/api/nova/IAccountResponse.ts @@ -0,0 +1,9 @@ +import { OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; + +export interface IAccountResponse extends IResponse { + /** + * The account details response. + */ + accountDetails?: OutputResponse; +} diff --git a/client/src/models/api/nova/IAssociationsResponse.ts b/client/src/models/api/nova/IAssociationsResponse.ts new file mode 100644 index 000000000..e64c711c7 --- /dev/null +++ b/client/src/models/api/nova/IAssociationsResponse.ts @@ -0,0 +1,44 @@ +import { IResponse } from "../IResponse"; + +export enum AssociationType { + BASIC_ADDRESS, + BASIC_STORAGE_RETURN, + BASIC_EXPIRATION_RETURN, + BASIC_SENDER, + ACCOUNT_ADDRESS, + ACCOUNT_ISSUER, + ACCOUNT_SENDER, + ACCOUNT_ID, + ANCHOR_ID, + ANCHOR_STATE_CONTROLLER, + ANCHOR_GOVERNOR, + ANCHOR_ISSUER, + ANCHOR_SENDER, + DELEGATION_ADDRESS, + DELEGATION_VALIDATOR, + FOUNDRY_ACCOUNT, + NFT_ADDRESS, + NFT_STORAGE_RETURN, + NFT_EXPIRATION_RETURN, + NFT_ISSUER, + NFT_SENDER, + NFT_ID, +} + +export interface IAssociation { + /** + * The association for the output ids. + */ + type: AssociationType; + /** + * The output ids for the association. + */ + outputIds: string[]; +} + +export interface IAssociationsResponse extends IResponse { + /** + * The associations to output ids. + */ + associations?: IAssociation[]; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 20a66f842..c04f4b336 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -2,8 +2,12 @@ import { INetworkBoundGetRequest } from "~/models/api/INetworkBoundGetRequest"; import { IBlockRequest } from "~/models/api/nova/block/IBlockRequest"; import { IBlockResponse } from "~/models/api/nova/block/IBlockResponse"; import { IOutputDetailsRequest } from "~/models/api/IOutputDetailsRequest"; +import { IAccountRequest } from "~/models/api/nova/IAccountRequest"; +import { IAccountResponse } from "~/models/api/nova/IAccountResponse"; +import { IAssociationsResponse } from "~/models/api/nova/IAssociationsResponse"; import { INodeInfoResponse } from "~/models/api/nova/INodeInfoResponse"; import { IOutputDetailsResponse } from "~/models/api/nova/IOutputDetailsResponse"; +import { IAssociationsRequest } from "~/models/api/stardust/IAssociationsRequest"; import { ApiClient } from "../apiClient"; import { IBlockDetailsRequest } from "~/models/api/nova/block/IBlockDetailsRequest"; import { IBlockDetailsResponse } from "~/models/api/nova/block/IBlockDetailsResponse"; @@ -47,4 +51,26 @@ export class NovaApiClient extends ApiClient { public async outputDetails(request: IOutputDetailsRequest): Promise { return this.callApi(`nova/output/${request.network}/${request.outputId}`, "get"); } + + /** + * Get the account output details. + * @param request The request to send. + * @returns The response from the request. + */ + public async accountDetails(request: IAccountRequest): Promise { + return this.callApi(`nova/account/${request.network}/${request.accountId}`, "get"); + } + + /** + * Get the associated outputs. + * @param request The request to send. + * @returns The response from the request. + */ + public async associatedOutputs(request: IAssociationsRequest) { + return this.callApi( + `nova/output/associated/${request.network}/${request.addressDetails.bech32}`, + "post", + { addressDetails: request.addressDetails }, + ); + } }