From e5d7165cbb294ad217df6f0d76cd3427528d9222 Mon Sep 17 00:00:00 2001 From: Mario Date: Fri, 19 Jan 2024 18:06:32 +0100 Subject: [PATCH] Feat: Improve OutputView nested component (#959) * feat: Add bech32 address helper for nova and improve AddressView * feat: Add isPreExpanded and isLinksDisabled props to OutputView * feat: Add FeaturesView for nova and render features in OutputView * feat: Add top level fields for OutputView * fix: Fix error parsing blockIssuerKeys * chore: Bump sdk-nova to latest commit * feat: Fix nesting for Stored mana field + Fix label of Unlock conditions --- .../src/app/components/nova/AddressView.tsx | 11 +- .../src/app/components/nova/FeaturesView.tsx | 138 ++++++++++ client/src/app/components/nova/OutputView.tsx | 237 +++++++++++++++--- .../components/nova/UnlockConditionView.tsx | 25 +- client/src/app/routes/nova/OutputPage.tsx | 2 +- .../src/helpers/nova/bech32AddressHelper.ts | 78 ++++++ 6 files changed, 444 insertions(+), 47 deletions(-) create mode 100644 client/src/app/components/nova/FeaturesView.tsx 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 21879f977..44f7e5f43 100644 --- a/client/src/app/components/nova/AddressView.tsx +++ b/client/src/app/components/nova/AddressView.tsx @@ -1,15 +1,24 @@ 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/app/components/nova/FeaturesView.tsx b/client/src/app/components/nova/FeaturesView.tsx new file mode 100644 index 000000000..f07c5485a --- /dev/null +++ b/client/src/app/components/nova/FeaturesView.tsx @@ -0,0 +1,138 @@ +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).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 1c5cb48b4..697bb71eb 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -1,77 +1,238 @@ 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, + 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 { outputId: string; output: Output; showCopyAmount: boolean; + isPreExpanded?: boolean; + isLinksDisabled?: boolean; } -const OutputView: React.FC = ({ outputId, output, showCopyAmount }) => { - const [isExpanded, setIsExpanded] = React.useState(false); +const OutputView: React.FC = ({ outputId, output, showCopyAmount, isPreExpanded, isLinksDisabled }) => { + 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)}....${outputId.slice(-8, -4)}`; const outputIdIndexPart = outputId.slice(-4); - return ( -
-
setIsExpanded(!isExpanded)} className="card--value card-header--wrapper"> -
- -
-
- -
- ( - + const header = ( +
setIsExpanded(!isExpanded)} className="card--value card-header--wrapper"> +
+ +
+
+ +
+ ( + {isLinksDisabled ? ( +
+ {outputIdTransactionPart} + {outputIdIndexPart} +
+ ) : ( + {outputIdTransactionPart} {outputIdIndexPart} - ) - -
+ )} + ) +
- {showCopyAmount && ( -
- { - setIsFormattedBalance(!isFormattedBalance); - e.stopPropagation(); - }} - > - {output.amount} - -
- )} - {showCopyAmount && }
+ {showCopyAmount && ( +
+ { + setIsFormattedBalance(!isFormattedBalance); + e.stopPropagation(); + }} + > + {output.amount} + +
+ )} + {showCopyAmount && } +
+ ); + + 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) => ( ))} + {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) => ( + + ))}
)}
); }; +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: diff --git a/client/src/app/components/nova/UnlockConditionView.tsx b/client/src/app/components/nova/UnlockConditionView.tsx index 8e15ebbc3..52b16ead9 100644 --- a/client/src/app/components/nova/UnlockConditionView.tsx +++ b/client/src/app/components/nova/UnlockConditionView.tsx @@ -81,22 +81,33 @@ const UnlockConditionView: React.FC = ({ unlockConditi }; function getUnlockConditionTypeName(type: UnlockConditionType): string { + let name = null; + switch (type) { case UnlockConditionType.Address: - return "Address"; + name = "Address"; + break; case UnlockConditionType.StorageDepositReturn: - return "Storage deposit return"; + name = "Storage Deposit Return"; + break; case UnlockConditionType.Timelock: - return "Timelock"; + name = "Timelock"; + break; case UnlockConditionType.Expiration: - return "Expiration"; + name = "Expiration"; + break; case UnlockConditionType.GovernorAddress: - return "Governor address"; + name = "Governor Address"; + break; case UnlockConditionType.StateControllerAddress: - return "State controller address"; + name = "State Controller Address"; + break; case UnlockConditionType.ImmutableAccountAddress: - return "Immutable account address"; + name = "Immutable Account Address"; + break; } + + return name !== null ? `${name} Unlock Condition` : "Unknown Unlock condition"; } export default UnlockConditionView; diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index 5b94197d5..734772d28 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/bech32AddressHelper.ts b/client/src/helpers/nova/bech32AddressHelper.ts new file mode 100644 index 000000000..22ee6534f --- /dev/null +++ b/client/src/helpers/nova/bech32AddressHelper.ts @@ -0,0 +1,78 @@ +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"; + } + } +}