From bdb86296495accce3fc8cc5c52b678fa88b8d492 Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Thu, 11 Jan 2024 08:14:08 +0100 Subject: [PATCH 01/13] feat: Add bech32 address helper for nova and improve AddressView --- .../src/app/components/nova/AddressView.tsx | 15 +++- .../src/helpers/nova/bech32AddressHelper.ts | 87 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 client/src/helpers/nova/bech32AddressHelper.ts diff --git a/client/src/app/components/nova/AddressView.tsx b/client/src/app/components/nova/AddressView.tsx index fefd76b22..6e3a19ec2 100644 --- a/client/src/app/components/nova/AddressView.tsx +++ b/client/src/app/components/nova/AddressView.tsx @@ -1,18 +1,31 @@ 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"; interface AddressViewProps { address: Address; } const AddressView: React.FC = ({ address }) => { + const { name: networkName, bech32Hrp } = useNetworkInfoNova( + (s) => s.networkInfo, + ); + const addressDetails = Bech32AddressHelper.buildAddress(bech32Hrp, address); + const link = `/${networkName}/addr/${addressDetails.bech32}`; + return (
{getAddressTypeName(address.type)}
- {JSON.stringify(address)} +
); diff --git a/client/src/helpers/nova/bech32AddressHelper.ts b/client/src/helpers/nova/bech32AddressHelper.ts new file mode 100644 index 000000000..471ce0cc5 --- /dev/null +++ b/client/src/helpers/nova/bech32AddressHelper.ts @@ -0,0 +1,87 @@ +import { Bech32Helper } from "@iota/iota.js"; +import { Address, AddressType, AccountAddress, Ed25519Address, NftAddress } from "@iota/sdk-wasm-nova/web"; +import { Converter } from "../stardust/convertUtils"; +import { HexHelper } from "../stardust/hexHelper"; +import { IBech32AddressDetails } from "~models/api/IBech32AddressDetails"; + +export class Bech32AddressHelper { + /** + * Build the address details. + * @param hrp The human readable part of the address. + * @param address The address to source the data from. + * @param typeHint The type of the address. + * @returns The parts of the address. + */ + public static buildAddress(hrp: string, address: string | Address, typeHint?: number): IBech32AddressDetails { + return typeof address === "string" + ? this.buildAddressFromString(hrp, address, typeHint) + : this.buildAddressFromTypes(hrp, address); + } + + private static buildAddressFromString(hrp: string, address: string, typeHint?: number): IBech32AddressDetails { + let bech32; + let hex; + let type; + + if (Bech32Helper.matches(address, hrp)) { + try { + const result = Bech32Helper.fromBech32(address, hrp); + if (result) { + bech32 = address; + type = result.addressType; + hex = Converter.bytesToHex(result.addressBytes, true); + } + } catch { + } + } + + if (!bech32) { + // We assume this is hex and either use the hint or assume ed25519 for now + hex = address; + type = typeHint ?? AddressType.Ed25519; + bech32 = Bech32Helper.toBech32(type, Converter.hexToBytes(hex), hrp); + } + + return { + bech32, + hex, + type, + typeLabel: Bech32AddressHelper.typeLabel(type) + }; + } + + private static buildAddressFromTypes(hrp: string, address: Address): IBech32AddressDetails { + let hex: string = ""; + + if (address.type === AddressType.Ed25519) { + hex = HexHelper.stripPrefix( + (address as Ed25519Address).pubKeyHash + ); + } else if (address.type === AddressType.Account) { + hex = HexHelper.stripPrefix( + (address as AccountAddress).accountId + ); + } else if (address.type === AddressType.Nft) { + hex = HexHelper.stripPrefix( + (address as NftAddress).nftId + ); + } + + return this.buildAddressFromString(hrp, hex, address.type); + } + + /** + * Convert the address type number to a label. + * @param addressType The address type to get the label for. + * @returns The label. + */ + private static typeLabel(addressType?: number): string | undefined { + if (addressType === AddressType.Ed25519) { + return "Ed25519"; + } else if (addressType === AddressType.Account) { + return "Account"; + } else if (addressType === AddressType.Nft) { + return "NFT"; + } + } +} From 9bf64aefa52750645290917cb1f5f8d570361f05 Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Thu, 11 Jan 2024 08:48:48 +0100 Subject: [PATCH 02/13] feat: Add isPreExpanded and isLinksDisabled props to OutputView --- client/src/app/components/nova/OutputView.tsx | 42 +++++++++++++------ client/src/app/routes/nova/OutputPage.tsx | 1 + 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx index c7b74150b..7bd2db6ec 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -12,18 +12,25 @@ interface OutputViewProps { outputId: string; output: Output; showCopyAmount: boolean; + isPreExpanded?: boolean; + isLinksDisabled?: boolean; } const OutputView: React.FC = ({ outputId, output, showCopyAmount, + isPreExpanded, + isLinksDisabled, }) => { - const [isExpanded, setIsExpanded] = React.useState(false); + const [isExpanded, setIsExpanded] = React.useState(isPreExpanded ?? false); const [isFormattedBalance, setIsFormattedBalance] = React.useState(true); - const networkInfo = useNetworkInfoNova(s => s.networkInfo); + const networkInfo = useNetworkInfoNova((s) => s.networkInfo); - const outputIdTransactionPart = `${outputId.slice(0, 8)}....${outputId.slice(-8, -4)}`; + const outputIdTransactionPart = `${outputId.slice( + 0, + 8, + )}....${outputId.slice(-8, -4)}`; const outputIdIndexPart = outputId.slice(-4); return ( @@ -45,15 +52,26 @@ const OutputView: React.FC = ({
( - - {outputIdTransactionPart} - - {outputIdIndexPart} - - + {isLinksDisabled ? ( +
+ + {outputIdTransactionPart} + + + {outputIdIndexPart} + +
+ ) : ( + + {outputIdTransactionPart} + + {outputIdIndexPart} + + + )} )
diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index 964b345a2..7b7eaf04f 100644 --- a/client/src/app/routes/nova/OutputPage.tsx +++ b/client/src/app/routes/nova/OutputPage.tsx @@ -68,6 +68,7 @@ const OutputPage: React.FC> = ({ outputId={outputId} output={output} showCopyAmount={true} + isPreExpanded={true} /> From 56ee851a86f47416f7a38a3fda61a8a716c530b7 Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Thu, 11 Jan 2024 10:25:16 +0100 Subject: [PATCH 03/13] feat: Add FeaturesView for nova and render features in OutputView --- .../src/app/components/nova/FeaturesView.tsx | 201 ++++++++++++++++++ client/src/app/components/nova/OutputView.tsx | 178 +++++++++++----- 2 files changed, 324 insertions(+), 55 deletions(-) create mode 100644 client/src/app/components/nova/FeaturesView.tsx diff --git a/client/src/app/components/nova/FeaturesView.tsx b/client/src/app/components/nova/FeaturesView.tsx new file mode 100644 index 000000000..e3cc66cb2 --- /dev/null +++ b/client/src/app/components/nova/FeaturesView.tsx @@ -0,0 +1,201 @@ +import { + BlockIssuerFeature, + Feature, + FeatureType, + IssuerFeature, + MetadataFeature, + NativeTokenFeature, + SenderFeature, + StakingFeature, + TagFeature, +} from "@iota/sdk-wasm-nova/web"; +// will this import work ? why isnt it exported from web ? +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 DropdownIcon from "~assets/dropdown-arrow.svg?react"; +import DataToggle from "../DataToggle"; + +interface FeatureViewProps { + /** + * The feature. + */ + feature: Feature; + + /** + * Is the feature pre-expanded. + */ + isPreExpanded?: boolean; + + /** + * Is the feature immutable. + */ + isImmutable: boolean; +} + +const FeatureView: React.FC = ({ + feature, + isImmutable, + isPreExpanded, +}) => { + const [isExpanded, setIsExpanded] = useState( + isPreExpanded ?? false, + ); + + return ( +
+
setIsExpanded(!isExpanded)} + > +
+ +
+
+ {getFeatureTypeName(feature.type, isImmutable)} +
+
+ {isExpanded && ( +
+ {feature.type === FeatureType.Sender && ( + + )} + {feature.type === FeatureType.Issuer && ( + + )} + {feature.type === FeatureType.Metadata && ( +
+ +
+ )} + {feature.type === FeatureType.StateMetadata && ( +
+ State metadata unimplemented +
+ )} + {feature.type === FeatureType.Tag && ( +
+ {(feature as TagFeature).tag && ( + + )} +
+ )} + {feature.type === FeatureType.NativeToken && ( +
+
Token id:
+
+ {(feature as NativeTokenFeature).id} +
+
Amount:
+
+ {Number((feature as NativeTokenFeature).amount)} +
+
+ )} + {feature.type === FeatureType.BlockIssuer && ( +
+
Expiry Slot:
+
+ {(feature as BlockIssuerFeature).expirySlot} +
+
+ Block issuer keys: +
+ {Array.from( + ( + feature as BlockIssuerFeature + ).blockIssuerKeys.values(), + ).map((blockIssuerKey, idx) => ( +
+ { + ( + blockIssuerKey as Ed25519BlockIssuerKey + ).publicKey + } +
+ ))} +
+ )} + {feature.type === FeatureType.Staking && ( +
+
Staked amount:
+
+ {Number( + (feature as StakingFeature).stakedAmount, + )} +
+
Fixed cost:
+
+ {Number((feature as StakingFeature).fixedCost)} +
+
Start epoch:
+
+ {Number((feature as StakingFeature).startEpoch)} +
+
End epoch:
+
+ {Number((feature as StakingFeature).endEpoch)} +
+
+ )} +
+ )} +
+ ); +}; + +function getFeatureTypeName(type: FeatureType, isImmutable: boolean): string { + let name: string = ""; + + switch (type) { + case FeatureType.Sender: + name = "Sender"; + break; + case FeatureType.Issuer: + name = "Issuer"; + break; + case FeatureType.Metadata: + name = "Metadata"; + break; + case FeatureType.StateMetadata: + name = "State Metadata"; + break; + case FeatureType.Tag: + name = "Tag"; + break; + case FeatureType.NativeToken: + name = "Native Token"; + break; + case FeatureType.BlockIssuer: + name = "Block Issuer"; + break; + case FeatureType.Staking: + name = "Staking"; + break; + } + + if (name) { + return isImmutable ? `Immutable ${name}` : name; + } + + return "Unknown Feature"; +} + +export default FeatureView; diff --git a/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx index 7bd2db6ec..c945067df 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -1,11 +1,20 @@ import React from "react"; import DropdownIcon from "~assets/dropdown-arrow.svg?react"; import classNames from "classnames"; -import { Output, OutputType, CommonOutput } from "@iota/sdk-wasm-nova/web"; +import { + Output, + OutputType, + CommonOutput, + AccountOutput, + AnchorOutput, + FoundryOutput, + NftOutput, +} from "@iota/sdk-wasm-nova/web"; import UnlockConditionView from "./UnlockConditionView"; import CopyButton from "../CopyButton"; import { Link } from "react-router-dom"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import FeatureView from "./FeaturesView"; import "./OutputView.scss"; interface OutputViewProps { @@ -33,64 +42,68 @@ const OutputView: React.FC = ({ )}....${outputId.slice(-8, -4)}`; const outputIdIndexPart = outputId.slice(-4); - return ( -
+ const header = ( +
setIsExpanded(!isExpanded)} + className="card--value card-header--wrapper" + >
setIsExpanded(!isExpanded)} - className="card--value card-header--wrapper" + className={classNames("card--content--dropdown", { + opened: isExpanded, + })} > -
- -
-
- -
- ( - {isLinksDisabled ? ( -
- - {outputIdTransactionPart} - - - {outputIdIndexPart} - -
- ) : ( - - {outputIdTransactionPart} - - {outputIdIndexPart} - - - )} - ) - -
-
- {showCopyAmount && ( -
- { - setIsFormattedBalance(!isFormattedBalance); - e.stopPropagation(); - }} + +
+
+ +
+ ( + {isLinksDisabled ? ( +
+ + {outputIdTransactionPart} + + + {outputIdIndexPart} + +
+ ) : ( + - {output.amount} - -
- )} - {showCopyAmount && } + {outputIdTransactionPart} + + {outputIdIndexPart} + + + )} + ) + +
+ {showCopyAmount && ( +
+ { + setIsFormattedBalance(!isFormattedBalance); + e.stopPropagation(); + }} + > + {output.amount} + +
+ )} + {showCopyAmount && } +
+ ); + + return ( +
+ {header} {isExpanded && (
{(output as CommonOutput).unlockConditions?.map( @@ -102,6 +115,61 @@ const OutputView: React.FC = ({ /> ), )} + {output.type !== OutputType.Delegation && + (output as CommonOutput).features?.map( + (feature, idx) => ( + + ), + )} + {output.type === OutputType.Account && + (output as AccountOutput).immutableFeatures?.map( + (immutableFeature, idx) => ( + + ), + )} + {output.type === OutputType.Anchor && + (output as AnchorOutput).immutableFeatures?.map( + (immutableFeature, idx) => ( + + ), + )} + {output.type === OutputType.Nft && + (output as NftOutput).immutableFeatures?.map( + (immutableFeature, idx) => ( + + ), + )} + {output.type === OutputType.Foundry && + (output as FoundryOutput).immutableFeatures?.map( + (immutableFeature, idx) => ( + + ), + )}
)}
From 25f0211172b738acd7328ca418cc82d2b3f13edf Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Thu, 11 Jan 2024 11:43:31 +0100 Subject: [PATCH 04/13] feat: Add top level fields for OutputView --- client/src/app/components/nova/OutputView.tsx | 180 +++++++++++++++++- 1 file changed, 178 insertions(+), 2 deletions(-) diff --git a/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx index c945067df..7017f1869 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -4,17 +4,25 @@ import classNames from "classnames"; import { Output, OutputType, + BasicOutput, CommonOutput, AccountOutput, AnchorOutput, FoundryOutput, NftOutput, + TokenSchemeType, + SimpleTokenScheme, + DelegationOutput, + AddressType, } from "@iota/sdk-wasm-nova/web"; import UnlockConditionView from "./UnlockConditionView"; import CopyButton from "../CopyButton"; import { Link } from "react-router-dom"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; import FeatureView from "./FeaturesView"; +import TruncatedId from "../stardust/TruncatedId"; +import { TransactionsHelper } from "~/helpers/stardust/transactionsHelper"; +import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; import "./OutputView.scss"; interface OutputViewProps { @@ -34,8 +42,9 @@ const OutputView: React.FC = ({ }) => { const [isExpanded, setIsExpanded] = React.useState(isPreExpanded ?? false); const [isFormattedBalance, setIsFormattedBalance] = React.useState(true); - const networkInfo = useNetworkInfoNova((s) => s.networkInfo); + const { name: networkName, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const aliasOrNftBech32 = buildAddressForAliasOrNft(outputId, output, bech32Hrp); const outputIdTransactionPart = `${outputId.slice( 0, 8, @@ -71,7 +80,7 @@ const OutputView: React.FC = ({
) : ( {outputIdTransactionPart} @@ -101,11 +110,154 @@ const OutputView: React.FC = ({ ); + const topLevelFields = ( + + {output.type === OutputType.Account && ( + +
Account address:
+
+ +
+
Foundry counter:
+
+ {(output as AccountOutput).foundryCounter} +
+
+ )} + {output.type === OutputType.Anchor && ( + +
Anchor Id:
+
+ +
+
Staten index:
+
+ {(output as AnchorOutput).stateIndex} +
+
+ )} + {output.type === OutputType.Nft && ( + +
Nft address:
+
+ +
+
+ )} + {output.type === OutputType.Foundry && ( + +
Serial number:
+
+ {(output as FoundryOutput).serialNumber} +
+
Token scheme type:
+
+ {(output as FoundryOutput).tokenScheme.type} +
+ {(output as FoundryOutput).tokenScheme.type === + TokenSchemeType.Simple && ( + +
Minted tokens:
+
+ {Number( + ( + (output as FoundryOutput) + .tokenScheme as SimpleTokenScheme + ).mintedTokens, + )} +
+
Melted tokens:
+
+ {Number( + ( + (output as FoundryOutput) + .tokenScheme as SimpleTokenScheme + ).meltedTokens, + )} +
+
Maximum supply:
+
+ {Number( + ( + (output as FoundryOutput) + .tokenScheme as SimpleTokenScheme + ).maximumSupply, + )} +
+
+ )} +
+ )} + {output.type === OutputType.Basic || + output.type === OutputType.Account || + output.type === OutputType.Anchor || + (output.type === OutputType.Nft && ( + +
Stored mana:
+
+ {(output as BasicOutput).mana?.toString()} +
+
+ ))} + {output.type === OutputType.Delegation && ( + +
Delegated amount:
+
+ {Number((output as DelegationOutput).delegatedAmount)} +
+
Delegation Id:
+
+ {(output as DelegationOutput).delegationId} +
+
Validator Id:
+
+ {(output as DelegationOutput).validatorId} +
+
Start epoch:
+
+ {(output as DelegationOutput).startEpoch} +
+
End epoch:
+
+ {(output as DelegationOutput).endEpoch} +
+
+ )} +
+ ); + return (
{header} {isExpanded && (
+ {topLevelFields} {(output as CommonOutput).unlockConditions?.map( (unlockCondition, idx) => ( = ({ ); }; +function buildAddressForAliasOrNft(outputId: string, output: Output, bech32Hrp: string) { + let address: string = ""; + let addressType: number = 0; + + if (output.type === OutputType.Account) { + const aliasId = TransactionsHelper.buildIdHashForNft( + (output as AccountOutput).accountId, + outputId, + ); + address = aliasId; + addressType = AddressType.Account; + } else if (output.type === OutputType.Nft) { + const nftId = TransactionsHelper.buildIdHashForAlias( + (output as NftOutput).nftId, + outputId, + ); + address = nftId; + addressType = AddressType.Nft; + } + + return Bech32AddressHelper.buildAddress(bech32Hrp, address, addressType) + .bech32; +} + function getOutputTypeName(type: OutputType): string { switch (type) { case OutputType.Basic: From 786f39544985c09eaaa139c7455a4131590e6b4a Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Fri, 12 Jan 2024 14:27:29 +0100 Subject: [PATCH 05/13] fix: Fix error parsing blockIssuerKeys --- client/src/app/components/nova/FeaturesView.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/app/components/nova/FeaturesView.tsx b/client/src/app/components/nova/FeaturesView.tsx index e3cc66cb2..c9001036d 100644 --- a/client/src/app/components/nova/FeaturesView.tsx +++ b/client/src/app/components/nova/FeaturesView.tsx @@ -119,9 +119,7 @@ const FeatureView: React.FC = ({ Block issuer keys:
{Array.from( - ( - feature as BlockIssuerFeature - ).blockIssuerKeys.values(), + (feature as BlockIssuerFeature).blockIssuerKeys, ).map((blockIssuerKey, idx) => (
{ From 6b0e933c5d655b8478d8ae11486faa503e66e3a5 Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Mon, 15 Jan 2024 13:23:30 +0100 Subject: [PATCH 06/13] chore: Bump sdk-nova to latest commit --- api/package-lock.json | 4 +++- client/package-lock.json | 2 +- setup_nova.sh | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 6fc4f6198..46280d5fc 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -64,10 +64,11 @@ }, "../iota-sdk/bindings/nodejs": { "name": "@iota/sdk-nova", - "version": "1.1.3", + "version": "1.1.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "@babel/traverse": "^7.23.2", "@types/node": "^18.15.12", "class-transformer": "^0.5.1", "prebuild-install": "^7.1.1", @@ -11313,6 +11314,7 @@ "@iota/sdk-nova": { "version": "file:../iota-sdk/bindings/nodejs", "requires": { + "@babel/traverse": "^7.23.2", "@napi-rs/cli": "^1.0.0", "@types/jest": "^29.4.0", "@types/node": "^18.15.12", diff --git a/client/package-lock.json b/client/package-lock.json index cc8e515b3..2ba6e335f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -102,7 +102,7 @@ }, "../iota-sdk/bindings/wasm": { "name": "@iota/sdk-wasm-nova", - "version": "1.1.1", + "version": "1.1.2", "license": "Apache-2.0", "dependencies": { "class-transformer": "^0.5.1", diff --git a/setup_nova.sh b/setup_nova.sh index 9847b59fc..db0390697 100755 --- a/setup_nova.sh +++ b/setup_nova.sh @@ -1,6 +1,6 @@ #!/bin/bash SDK_DIR="iota-sdk" -TARGET_COMMIT="6628d8ade72a14d0f25eae859590f0bdcea0cf83" +TARGET_COMMIT="8d31e6b6648c1dbd8dcc3777e35bf9865bf2f983" if [ ! -d "$SDK_DIR" ]; then git clone -b 2.0 git@github.com:iotaledger/iota-sdk.git From 2b56088ea9bfd49d44a114c3848e7d33385ae2aa Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Tue, 16 Jan 2024 18:14:34 +0100 Subject: [PATCH 07/13] feat: Add basic infra for Address page (nova) --- api/src/models/api/nova/IAccountRequest.ts | 11 + api/src/models/api/nova/IAccountResponse.ts | 9 + api/src/routes.ts | 1 + api/src/routes/nova/account/get.ts | 29 ++ api/src/services/nova/novaApi.ts | 18 ++ client/src/app/components/nova/OutputView.tsx | 13 +- client/src/app/routes.tsx | 2 + client/src/app/routes/nova/AddressPage.scss | 283 ++++++++++++++++++ client/src/app/routes/nova/AddressPage.tsx | 72 +++++ client/src/app/routes/nova/OutputPage.tsx | 2 +- .../helpers/nova/hooks/useAccountDetails.ts | 48 +++ .../helpers/nova/hooks/useAddressPageState.ts | 76 +++++ client/src/models/api/nova/IAccountRequest.ts | 11 + .../src/models/api/nova/IAccountResponse.ts | 9 + client/src/services/nova/novaApiClient.ts | 11 + 15 files changed, 588 insertions(+), 7 deletions(-) create mode 100644 api/src/models/api/nova/IAccountRequest.ts create mode 100644 api/src/models/api/nova/IAccountResponse.ts create mode 100644 api/src/routes/nova/account/get.ts create mode 100644 client/src/app/routes/nova/AddressPage.scss create mode 100644 client/src/app/routes/nova/AddressPage.tsx create mode 100644 client/src/helpers/nova/hooks/useAccountDetails.ts create mode 100644 client/src/helpers/nova/hooks/useAddressPageState.ts create mode 100644 client/src/models/api/nova/IAccountRequest.ts create mode 100644 client/src/models/api/nova/IAccountResponse.ts 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..36449c9ec --- /dev/null +++ b/api/src/models/api/nova/IAccountResponse.ts @@ -0,0 +1,9 @@ +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/routes.ts b/api/src/routes.ts index 5e61ef69d..8da18e6b4 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -203,4 +203,5 @@ 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" }, ]; 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/services/nova/novaApi.ts b/api/src/services/nova/novaApi.ts index 6ed80f6a8..ca4915db0 100644 --- a/api/src/services/nova/novaApi.ts +++ b/api/src/services/nova/novaApi.ts @@ -1,5 +1,6 @@ import { __ClientMethods__, OutputResponse, Client } from "@iota/sdk-nova"; import { ServiceFactory } from "../../factories/serviceFactory"; +import { IAccountResponse } from "../../models/api/nova/IAccountResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; import { INetwork } from "../../models/db/INetwork"; @@ -22,6 +23,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 aliasOutputId = await this.tryFetchNodeThenPermanode(accountId, "accountOutputId", network); + + if (aliasOutputId) { + const outputResponse = await this.outputDetails(network, aliasOutputId); + return outputResponse.error ? { error: outputResponse.error } : { accountDetails: outputResponse.output }; + } + + return { message: "Alias 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/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx index fcd881f01..7270c6655 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -26,6 +26,7 @@ import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; import "./OutputView.scss"; interface OutputViewProps { + network: string; outputId: string; output: Output; showCopyAmount: boolean; @@ -33,10 +34,10 @@ interface OutputViewProps { isLinksDisabled?: boolean; } -const OutputView: React.FC = ({ outputId, output, showCopyAmount, isPreExpanded, isLinksDisabled }) => { +const OutputView: React.FC = ({ network, 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 } = useNetworkInfoNova((s) => s.networkInfo); const aliasOrNftBech32 = buildAddressForAliasOrNft(outputId, output, bech32Hrp); const outputIdTransactionPart = `${outputId.slice(0, 8)}....${outputId.slice(-8, -4)}`; @@ -63,7 +64,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun {outputIdIndexPart}
) : ( - + {outputIdTransactionPart} {outputIdIndexPart} @@ -97,7 +98,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun
@@ -111,7 +112,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun
@@ -125,7 +126,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun
diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 002827986..0cbd1d6ae 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"; @@ -166,6 +167,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..d63cc180e --- /dev/null +++ b/client/src/app/routes/nova/AddressPage.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { RouteComponentProps } from "react-router-dom"; +import Modal from "~/app/components/Modal"; +import NotFound from "~/app/components/NotFound"; +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: { network, address }, + }, +}) => { + const [state] = useAddressPageState(); + const { bech32AddressDetails, isAccountDetailsLoading } = state; + + if (!bech32AddressDetails) { + renderAddressNotFound(address); + } + + const isPageLoading = isAccountDetailsLoading; + + return ( +
+
+ {bech32AddressDetails && ( +
+
+
+

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

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

General

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

Address

+ +
+
+ +
+
+
+); + +export default AddressPage; diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index 734772d28..ae2078cb2 100644 --- a/client/src/app/routes/nova/OutputPage.tsx +++ b/client/src/app/routes/nova/OutputPage.tsx @@ -60,7 +60,7 @@ 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..a85d0c643 --- /dev/null +++ b/client/src/helpers/nova/hooks/useAddressPageState.ts @@ -0,0 +1,76 @@ +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 addressBech32: string | null = state.bech32AddressDetails?.bech32 ?? null; + 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/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/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index ac7436038..73357b3fb 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -1,5 +1,7 @@ import { INetworkBoundGetRequest } from "~/models/api/INetworkBoundGetRequest"; import { IOutputDetailsRequest } from "~/models/api/IOutputDetailsRequest"; +import { IAccountRequest } from "~/models/api/nova/IAccountRequest"; +import { IAccountResponse } from "~/models/api/nova/IAccountResponse"; import { INodeInfoResponse } from "~/models/api/nova/INodeInfoResponse"; import { IOutputDetailsResponse } from "~/models/api/nova/IOutputDetailsResponse"; import { ApiClient } from "../apiClient"; @@ -25,4 +27,13 @@ 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"); + } } From 095ec4ae4b1255dcd3d16cd9902aed42339da1fc Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Thu, 18 Jan 2024 12:35:12 +0100 Subject: [PATCH 08/13] feat: Add Associated Outputs section to Address page (nova) --- .../models/api/nova/IAssociationsResponse.ts | 44 +++ api/src/routes.ts | 7 + api/src/routes/nova/output/associated/post.ts | 46 +++ api/src/utils/nova/associatedOutputsHelper.ts | 290 ++++++++++++++++++ .../src/app/components/nova/FeaturesView.tsx | 2 +- .../components/nova/UnlockConditionView.tsx | 2 +- .../nova/{ => address}/AddressView.tsx | 2 +- .../association/AssociatedOutputs.scss | 170 ++++++++++ .../section/association/AssociatedOutputs.tsx | 88 ++++++ .../association/AssociatedOutputsUtils.ts | 92 ++++++ .../association/AssociationSection.scss | 173 +++++++++++ .../association/AssociationSection.tsx | 153 +++++++++ client/src/app/routes/nova/AddressPage.tsx | 7 + .../nova/hooks/useAssociatedOutputs.ts | 51 +++ .../helpers/nova/hooks/useOutputsDetails.ts | 77 +++++ .../models/api/nova/IAssociationsResponse.ts | 44 +++ client/src/services/nova/novaApiClient.ts | 15 + 17 files changed, 1260 insertions(+), 3 deletions(-) create mode 100644 api/src/models/api/nova/IAssociationsResponse.ts create mode 100644 api/src/routes/nova/output/associated/post.ts create mode 100644 api/src/utils/nova/associatedOutputsHelper.ts rename client/src/app/components/nova/{ => address}/AddressView.tsx (96%) create mode 100644 client/src/app/components/nova/address/section/association/AssociatedOutputs.scss create mode 100644 client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx create mode 100644 client/src/app/components/nova/address/section/association/AssociatedOutputsUtils.ts create mode 100644 client/src/app/components/nova/address/section/association/AssociationSection.scss create mode 100644 client/src/app/components/nova/address/section/association/AssociationSection.tsx create mode 100644 client/src/helpers/nova/hooks/useAssociatedOutputs.ts create mode 100644 client/src/helpers/nova/hooks/useOutputsDetails.ts create mode 100644 client/src/models/api/nova/IAssociationsResponse.ts 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 8da18e6b4..7cc226ca2 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -204,4 +204,11 @@ 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, + }, ]; 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/utils/nova/associatedOutputsHelper.ts b/api/src/utils/nova/associatedOutputsHelper.ts new file mode 100644 index 000000000..c16ead566 --- /dev/null +++ b/api/src/utils/nova/associatedOutputsHelper.ts @@ -0,0 +1,290 @@ +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/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/UnlockConditionView.tsx b/client/src/app/components/nova/UnlockConditionView.tsx index 8e15ebbc3..0f254d513 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..baf38f446 --- /dev/null +++ b/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx @@ -0,0 +1,88 @@ +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 { IBech32AddressDetails } from "~models/api/IBech32AddressDetails"; +import { AssociationType, IAssociation } from "~models/api/nova/IAssociationsResponse"; +import "./AssociatedOutputs.scss"; + +interface AssociatedOutputsProps { + /** + * The network in context. + */ + readonly network: string; + /** + * 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 = ({ network, addressDetails, setOutputCount, setIsLoading }) => { + 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..53b742ee0 --- /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("Account"); + } + 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..b05c114c4 --- /dev/null +++ b/client/src/app/components/nova/address/section/association/AssociationSection.tsx @@ -0,0 +1,153 @@ +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 network: string; + readonly association: AssociationType; + readonly outputIds: string[] | undefined; +} + +interface IOutputTableItem { + outputId: string; + amount: string; +} + +const PAGE_SIZE = 10; + +const AssociationSection: React.FC = ({ network, association, outputIds }) => { + const { tokenInfo } = 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/routes/nova/AddressPage.tsx b/client/src/app/routes/nova/AddressPage.tsx index d63cc180e..9d1b0e702 100644 --- a/client/src/app/routes/nova/AddressPage.tsx +++ b/client/src/app/routes/nova/AddressPage.tsx @@ -2,6 +2,7 @@ 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"; @@ -46,6 +47,12 @@ const AddressPage: React.FC> = ({
+
+
+

Associated Outputs

+
+ +
)} 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/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 73357b3fb..628828a92 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -2,8 +2,10 @@ import { INetworkBoundGetRequest } from "~/models/api/INetworkBoundGetRequest"; 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"; /** @@ -36,4 +38,17 @@ export class NovaApiClient extends ApiClient { 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 }, + ); + } } From 291cc45ddc6053b685653cd6915799fa16192949 Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Tue, 23 Jan 2024 23:46:24 +0100 Subject: [PATCH 09/13] fix: ignore sdk-nova unresolved import --- api/src/models/api/nova/IAccountResponse.ts | 2 ++ api/src/utils/nova/associatedOutputsHelper.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/api/src/models/api/nova/IAccountResponse.ts b/api/src/models/api/nova/IAccountResponse.ts index 36449c9ec..4db772845 100644 --- a/api/src/models/api/nova/IAccountResponse.ts +++ b/api/src/models/api/nova/IAccountResponse.ts @@ -1,3 +1,5 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { OutputResponse } from "@iota/sdk-nova"; import { IResponse } from "./IResponse"; diff --git a/api/src/utils/nova/associatedOutputsHelper.ts b/api/src/utils/nova/associatedOutputsHelper.ts index c16ead566..0dfd2f7b7 100644 --- a/api/src/utils/nova/associatedOutputsHelper.ts +++ b/api/src/utils/nova/associatedOutputsHelper.ts @@ -1,3 +1,5 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { Client, IOutputsResponse, From b980538aeb9365f350e544144195cdaf28e6d0e9 Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Tue, 23 Jan 2024 23:52:24 +0100 Subject: [PATCH 10/13] fix: ignore sdk-nova unresolved import --- api/src/utils/nova/associatedOutputsHelper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/utils/nova/associatedOutputsHelper.ts b/api/src/utils/nova/associatedOutputsHelper.ts index 0dfd2f7b7..4d0693129 100644 --- a/api/src/utils/nova/associatedOutputsHelper.ts +++ b/api/src/utils/nova/associatedOutputsHelper.ts @@ -1,5 +1,6 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ import { Client, IOutputsResponse, From a7041892ed8d4e529f7cdcc7bc9d9eb3f6f8e9e4 Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Tue, 23 Jan 2024 23:58:38 +0100 Subject: [PATCH 11/13] fix: ignore sdk-nova unresolved import --- api/src/utils/nova/associatedOutputsHelper.ts | 1 + .../address/section/association/AssociationSection.tsx | 2 +- client/src/app/routes/nova/OutputPage.tsx | 8 +++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/api/src/utils/nova/associatedOutputsHelper.ts b/api/src/utils/nova/associatedOutputsHelper.ts index 4d0693129..688d26c6e 100644 --- a/api/src/utils/nova/associatedOutputsHelper.ts +++ b/api/src/utils/nova/associatedOutputsHelper.ts @@ -1,4 +1,5 @@ /* 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 { diff --git a/client/src/app/components/nova/address/section/association/AssociationSection.tsx b/client/src/app/components/nova/address/section/association/AssociationSection.tsx index b05c114c4..7a3daef51 100644 --- a/client/src/app/components/nova/address/section/association/AssociationSection.tsx +++ b/client/src/app/components/nova/address/section/association/AssociationSection.tsx @@ -25,7 +25,7 @@ interface IOutputTableItem { const PAGE_SIZE = 10; const AssociationSection: React.FC = ({ network, association, outputIds }) => { - const { tokenInfo } = useNetworkInfoNova(s => s.networkInfo); + const { tokenInfo } = useNetworkInfoNova((s) => s.networkInfo); const [isExpanded, setIsExpanded] = useState(false); const [isFormatBalance, setIsFormatBalance] = useState(false); const [loadMoreCounter, setLoadMoreCounter] = useState(); diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index ae2078cb2..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> = ({
- +
From caf1739ea3916f3ab7aa09d48f319bbaf92d377c Mon Sep 17 00:00:00 2001 From: Mario Sarcevic Date: Thu, 25 Jan 2024 12:32:41 +0100 Subject: [PATCH 12/13] fix: Fix PR feedback (Improve usage of networkName from useNetworkInfoNova) --- api/src/services/nova/novaApi.ts | 8 ++++---- client/src/app/AppUtils.tsx | 2 +- client/src/app/components/nova/Input.tsx | 6 +++--- client/src/app/components/nova/OutputView.tsx | 5 ++--- .../address/section/association/AssociatedOutputs.tsx | 9 +++------ .../section/association/AssociatedOutputsUtils.ts | 2 +- .../address/section/association/AssociationSection.tsx | 5 ++--- .../nova/block/payload/SignedTransactionPayload.tsx | 6 ++++-- client/src/app/routes/nova/AddressPage.tsx | 4 ++-- client/src/app/routes/nova/Block.tsx | 2 +- client/src/helpers/nova/hooks/useAddressPageState.ts | 1 - 11 files changed, 23 insertions(+), 27 deletions(-) diff --git a/api/src/services/nova/novaApi.ts b/api/src/services/nova/novaApi.ts index c99e6212c..53ee46fb9 100644 --- a/api/src/services/nova/novaApi.ts +++ b/api/src/services/nova/novaApi.ts @@ -79,14 +79,14 @@ export class NovaApi { * @returns The account details. */ public static async accountDetails(network: INetwork, accountId: string): Promise { - const aliasOutputId = await this.tryFetchNodeThenPermanode(accountId, "accountOutputId", network); + const accountOutputId = await this.tryFetchNodeThenPermanode(accountId, "accountOutputId", network); - if (aliasOutputId) { - const outputResponse = await this.outputDetails(network, aliasOutputId); + if (accountOutputId) { + const outputResponse = await this.outputDetails(network, accountOutputId); return outputResponse.error ? { error: outputResponse.error } : { accountDetails: outputResponse.output }; } - return { message: "Alias output not found" }; + return { message: "Account output not found" }; } /** 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/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 1f24f71c8..d138f6deb 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -26,7 +26,6 @@ import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; import "./OutputView.scss"; interface OutputViewProps { - network: string; outputId: string; output: Output; showCopyAmount: boolean; @@ -34,10 +33,10 @@ interface OutputViewProps { isLinksDisabled?: boolean; } -const OutputView: React.FC = ({ network, outputId, output, showCopyAmount, isPreExpanded, isLinksDisabled }) => { +const OutputView: React.FC = ({ outputId, output, showCopyAmount, isPreExpanded, isLinksDisabled }) => { const [isExpanded, setIsExpanded] = React.useState(isPreExpanded ?? false); const [isFormattedBalance, setIsFormattedBalance] = React.useState(true); - const { 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)}`; diff --git a/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx b/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx index baf38f446..186b1f1ec 100644 --- a/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx +++ b/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx @@ -3,15 +3,12 @@ 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 { - /** - * The network in context. - */ - readonly network: string; /** * Address details */ @@ -26,7 +23,8 @@ interface AssociatedOutputsProps { readonly setIsLoading?: (isLoading: boolean) => void; } -const AssociatedOutputs: React.FC = ({ network, addressDetails, setOutputCount, setIsLoading }) => { +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([]); @@ -69,7 +67,6 @@ const AssociatedOutputs: React.FC = ({ network, addressD ); return ( AssociationType[association.type].startsWith("DELEGATION"))) { - tabs.push("Account"); + tabs.push("Delegation"); } if (associations.some((association) => AssociationType[association.type].startsWith("FOUNDRY"))) { tabs.push("Foundry"); diff --git a/client/src/app/components/nova/address/section/association/AssociationSection.tsx b/client/src/app/components/nova/address/section/association/AssociationSection.tsx index 7a3daef51..104a6a7d9 100644 --- a/client/src/app/components/nova/address/section/association/AssociationSection.tsx +++ b/client/src/app/components/nova/address/section/association/AssociationSection.tsx @@ -12,7 +12,6 @@ import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; import "./AssociationSection.scss"; interface IAssociatedSectionProps { - readonly network: string; readonly association: AssociationType; readonly outputIds: string[] | undefined; } @@ -24,8 +23,8 @@ interface IOutputTableItem { const PAGE_SIZE = 10; -const AssociationSection: React.FC = ({ network, association, outputIds }) => { - const { tokenInfo } = useNetworkInfoNova((s) => s.networkInfo); +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(); diff --git a/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx b/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx index 553ada80d..f12f7f80d 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/nova/AddressPage.tsx b/client/src/app/routes/nova/AddressPage.tsx index 9d1b0e702..fd40a1a12 100644 --- a/client/src/app/routes/nova/AddressPage.tsx +++ b/client/src/app/routes/nova/AddressPage.tsx @@ -12,7 +12,7 @@ import "./AddressPage.scss"; const AddressPage: React.FC> = ({ match: { - params: { network, address }, + params: { address }, }, }) => { const [state] = useAddressPageState(); @@ -51,7 +51,7 @@ const AddressPage: React.FC> = ({

Associated Outputs

- +
)} 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/helpers/nova/hooks/useAddressPageState.ts b/client/src/helpers/nova/hooks/useAddressPageState.ts index a85d0c643..0f76327c8 100644 --- a/client/src/helpers/nova/hooks/useAddressPageState.ts +++ b/client/src/helpers/nova/hooks/useAddressPageState.ts @@ -37,7 +37,6 @@ export const useAddressPageState = (): [IAddressState, React.Dispatch Date: Thu, 25 Jan 2024 12:37:24 +0100 Subject: [PATCH 13/13] fix: Fix lint --- .../components/nova/block/payload/SignedTransactionPayload.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx b/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx index f12f7f80d..f22a9075b 100644 --- a/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx +++ b/client/src/app/components/nova/block/payload/SignedTransactionPayload.tsx @@ -16,7 +16,7 @@ interface SignedTransactionPayloadProps { const SignedTransactionPayload: React.FC = ({ payload, inputs, header }) => { const { outputs } = payload.transaction; - const { name: network } = useNetworkInfoNova(s => s.networkInfo); + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); const transactionId = Utils.transactionId(payload); return (