diff --git a/client/src/app/components/nova/FeaturesView.tsx b/client/src/app/components/nova/FeatureView.tsx similarity index 60% rename from client/src/app/components/nova/FeaturesView.tsx rename to client/src/app/components/nova/FeatureView.tsx index f38d219f9..2e782bbcb 100644 --- a/client/src/app/components/nova/FeaturesView.tsx +++ b/client/src/app/components/nova/FeatureView.tsx @@ -14,7 +14,13 @@ import classNames from "classnames"; import React, { useState } from "react"; import AddressView from "./address/AddressView"; import DropdownIcon from "~assets/dropdown-arrow.svg?react"; -import DataToggle from "../DataToggle"; +import DataToggle from "~/app/components/DataToggle"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { formatAmount } from "~/helpers/stardust/valueFormatHelper"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import Tooltip from "~/app/components/Tooltip"; +import { EPOCH_HINT } from "./OutputView"; +import { NameHelper } from "~/helpers/nova/nameHelper"; interface FeatureViewProps { /** @@ -34,6 +40,7 @@ interface FeatureViewProps { } const FeatureView: React.FC = ({ feature, isImmutable, isPreExpanded }) => { + const { name: network, tokenInfo, manaInfo } = useNetworkInfoNova((s) => s.networkInfo); const [isExpanded, setIsExpanded] = useState(isPreExpanded ?? false); return ( @@ -42,7 +49,7 @@ const FeatureView: React.FC = ({ feature, isImmutable, isPreEx
-
{getFeatureTypeName(feature.type, isImmutable)}
+
{NameHelper.getFeatureTypeName(feature.type, isImmutable)}
{isExpanded && (
@@ -52,8 +59,8 @@ const FeatureView: React.FC = ({ feature, isImmutable, isPreEx
{Object.entries((feature as MetadataFeature).entries).map(([key, value], index) => (
-
{key}
-
+
{key}:
+
@@ -89,13 +96,42 @@ const FeatureView: React.FC = ({ feature, isImmutable, isPreEx {feature.type === FeatureType.Staking && (
Staked amount:
-
{Number((feature as StakingFeature).stakedAmount)}
+
+ {formatAmount((feature as StakingFeature).stakedAmount, tokenInfo, false)} +
Fixed cost:
-
{Number((feature as StakingFeature).fixedCost)}
+
{formatAmount((feature as StakingFeature).fixedCost, manaInfo, false)}
Start epoch:
-
{Number((feature as StakingFeature).startEpoch)}
+
+ +
End epoch:
-
{Number((feature as StakingFeature).endEpoch)}
+
+ + {(feature as StakingFeature).endEpoch === 0 && ( + +
+ info +
+
+ )} +
)}
@@ -104,41 +140,4 @@ const FeatureView: React.FC = ({ feature, isImmutable, isPreEx ); }; -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.scss b/client/src/app/components/nova/OutputView.scss index 2b449d61c..9050a83cc 100644 --- a/client/src/app/components/nova/OutputView.scss +++ b/client/src/app/components/nova/OutputView.scss @@ -3,100 +3,131 @@ @import "../../../scss/mixins"; @import "../../../scss/fonts"; -.card--content__output { - padding: 0 30px; - margin-bottom: 20px; +.output-page { + .card--content__output { + padding: 0 30px; + margin-bottom: 20px; - @include phone-down { - padding: 0 4px; - } - - .card--value.card-header--wrapper { - width: 100%; - display: flex; - margin-bottom: 0px; - height: 32px; - align-items: center; + @include phone-down { + padding: 0 4px; + } - .output-header { - display: flex; + .card--value.card-header--wrapper { width: 100%; + display: flex; + margin-bottom: 0px; + height: 32px; + align-items: center; - .output-type--name { - white-space: nowrap; - } - - .output-id--link { + .output-header { display: flex; - margin-right: 2px; - white-space: nowrap; + width: 100%; - a { - margin-right: 0px; + .output-type--name { + white-space: nowrap; } - .highlight { - font-weight: 500; - color: $gray-6; - margin-left: 2px; + .output-id--link { + display: flex; + margin-right: 2px; + white-space: nowrap; + + a { + margin-right: 0px; + } + + .highlight { + font-weight: 500; + color: $gray-6; + margin-left: 2px; + } + + .copy-button { + margin-left: 2px; + } } + } - .copy-button { - margin-left: 2px; + .amount-size { + width: min-content; + text-align: end; + word-break: normal; + white-space: nowrap; + cursor: pointer; + margin-bottom: 0; + margin-right: 4px; + + span { + word-break: keep-all; } } } - .amount-size { - width: min-content; - text-align: end; - word-break: normal; - white-space: nowrap; - cursor: pointer; - margin-bottom: 0; - margin-right: 4px; - - span { - word-break: keep-all; + .unlock-condition-icon { + color: #fec900; + font-size: 18px; + + &.expired { + color: #ff4800; } - } - } - .unlock-condition-icon { - color: #fec900; - font-size: 18px; + @media (min-width: #{768px}) and (max-width: #{820px}) { + display: none; + } - &.expired { - color: #ff4800; + @include phone-down { + display: none; + } } - @media (min-width: #{768px}) and (max-width: #{820px}) { - display: none; + .left-border { + border-left: 1px solid var(--border-color); } + } - @include phone-down { - display: none; + .card--content--dropdown { + margin-right: 8px; + cursor: pointer; + + svg { + transition: transform 0.25s ease; + + path { + fill: var(--card-color); + } } - } - .left-border { - border-left: 1px solid var(--border-color); + &.opened > svg { + transform: rotate(90deg); + } } } -.card--content--dropdown { - margin-right: 8px; - cursor: pointer; - - svg { - transition: transform 0.25s ease; +.epoch-info { + .material-icons { + margin-top: 6px; + padding-left: 5px; + font-size: 18px; + color: #b0bfd9; + } - path { - fill: var(--card-color); + .tooltip .wrap { + width: 230px !important; + .arrow { + left: 8px !important; + margin-left: 0 !important; + &--down { + top: 95%; + } } } - &.opened > svg { - transform: rotate(90deg); + &--above { + .tooltip .wrap { + top: -416%; + .arrow { + top: 94%; + } + } } } diff --git a/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx index 0c40b5496..afe55a184 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -34,10 +34,11 @@ import { IPreExpandedConfig } from "~models/components"; import CopyButton from "../CopyButton"; import Tooltip from "../Tooltip"; import TruncatedId from "../stardust/TruncatedId"; -import FeatureView from "./FeaturesView"; +import FeatureView from "./FeatureView"; import KeyValueEntries from "./KeyValueEntries"; import "./OutputView.scss"; import UnlockConditionView from "./UnlockConditionView"; +import { NameHelper } from "~/helpers/nova/nameHelper"; interface OutputViewProps { outputId: string; @@ -48,7 +49,11 @@ interface OutputViewProps { manaDetails?: OutputManaDetails | null; } +export const EPOCH_HINT = + "When the end epoch is set to 0, it indicates that no specific end epoch has been defined for this delegation output."; + const OutputView: React.FC = ({ outputId, output, showCopyAmount, preExpandedConfig, isLinksDisabled, manaDetails }) => { + const { manaInfo } = useNetworkInfoNova((s) => s.networkInfo); const [isExpanded, setIsExpanded] = useState(preExpandedConfig?.isAllPreExpanded ?? preExpandedConfig?.isPreExpanded ?? false); const [isFormattedBalance, setIsFormattedBalance] = useState(true); const { bech32Hrp, name: network, protocolInfo, tokenInfo } = useNetworkInfoNova((s) => s.networkInfo); @@ -57,8 +62,11 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun const accountOrNftBech32 = buildAddressForAccountOrNft(outputId, output, bech32Hrp); const outputIdTransactionPart = `${outputId.slice(0, 8)}....${outputId.slice(-8, -4)}`; const outputIdIndexPart = outputId.slice(-4); - const manaEntries = manaDetails ? getManaKeyValueEntries(manaDetails) : undefined; + const manaEntries = manaDetails ? getManaKeyValueEntries(manaDetails, manaInfo, output.type === OutputType.Delegation) : undefined; const isSpecialCondition = hasSpecialCondition(output as CommonOutput); + const validatorAddress = + output.type === OutputType.Delegation ? Utils.addressToBech32((output as DelegationOutput).validatorAddress, bech32Hrp) : ""; + const delegationId = getDelegationId(outputId, output); useEffect(() => { setIsExpanded(preExpandedConfig?.isAllPreExpanded ?? preExpandedConfig?.isPreExpanded ?? isExpanded ?? false); @@ -93,7 +101,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun
( @@ -196,24 +204,54 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun )} )} - {(output.type === OutputType.Basic || - output.type === OutputType.Account || - output.type === OutputType.Anchor || - output.type === OutputType.Nft) && - manaEntries && - manaDetails?.totalMana && } + {output.type !== OutputType.Foundry && manaEntries && manaDetails?.totalMana && ( + + )} {output.type === OutputType.Delegation && (
Delegated amount:
{Number((output as DelegationOutput).delegatedAmount)}
Delegation Id:
-
{(output as DelegationOutput).delegationId}
+
{delegationId}
Validator Address:
-
{Utils.addressToBech32((output as DelegationOutput).validatorAddress, bech32Hrp)}
+
+ +
Start epoch:
-
{(output as DelegationOutput).startEpoch}
+
+ +
End epoch:
-
{(output as DelegationOutput).endEpoch}
+
+ + {(output as DelegationOutput).endEpoch === 0 && ( + +
+ info +
+
+ )} +
)} @@ -284,21 +322,23 @@ function buildAddressForAccountOrNft(outputId: string, output: Output, bech32Hrp return bech32; } -function getOutputTypeName(type: OutputType): string { - switch (type) { - case OutputType.Basic: - return "Basic"; - case OutputType.Account: - return "Account"; - case OutputType.Anchor: - return "Anchor"; - case OutputType.Foundry: - return "Foundry"; - case OutputType.Nft: - return "Nft"; - case OutputType.Delegation: - return "Delegation"; +/** + * Get delegation id for Delegation output. + * @param outputId The id of the output. + * @param output The output. + * @returns The delegation id. + */ +function getDelegationId(outputId: string, output: Output): string { + let delegationId: string = ""; + + if (output.type === OutputType.Delegation) { + const delegationIdFromOutput = (output as DelegationOutput).delegationId; + delegationId = HexHelper.toBigInt256(delegationIdFromOutput).eq(bigInt.zero) + ? Utils.computeDelegationId(outputId) + : delegationIdFromOutput; } + + return delegationId; } /** diff --git a/client/src/app/components/nova/address/FeaturesSection.tsx b/client/src/app/components/nova/address/FeaturesSection.tsx index c885b604d..4db0e7bef 100644 --- a/client/src/app/components/nova/address/FeaturesSection.tsx +++ b/client/src/app/components/nova/address/FeaturesSection.tsx @@ -1,7 +1,7 @@ import { AccountOutput, FoundryOutput, NftOutput } from "@iota/sdk-wasm-nova/web"; import { optional } from "@ruffy/ts-optional"; import React from "react"; -import FeatureView from "../FeaturesView"; +import FeatureView from "../FeatureView"; interface FeaturesSectionProps { /** diff --git a/client/src/app/components/nova/address/section/account/AccountValidatorSection.tsx b/client/src/app/components/nova/address/section/account/AccountValidatorSection.tsx index 4f3691755..2742bd87b 100644 --- a/client/src/app/components/nova/address/section/account/AccountValidatorSection.tsx +++ b/client/src/app/components/nova/address/section/account/AccountValidatorSection.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; import { OutputWithMetadataResponse, ValidatorResponse } from "@iota/sdk-wasm-nova/web"; +import React, { useState } from "react"; import TruncatedId from "~/app/components/stardust/TruncatedId"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; import { formatAmount } from "~/helpers/stardust/valueFormatHelper"; @@ -13,9 +13,8 @@ const AccountValidatorSection: React.FC = ({ valid if (!validatorDetails) { return null; } - const [isFormatBalance, setIsFormatBalance] = useState(false); - const { name: network, tokenInfo } = useNetworkInfoNova((state) => state.networkInfo); + const { name: network, tokenInfo, manaInfo } = useNetworkInfoNova((state) => state.networkInfo); const delegatedStake = BigInt(validatorDetails.poolStake) - BigInt(validatorDetails.validatorStake); @@ -32,19 +31,19 @@ const AccountValidatorSection: React.FC = ({ valid
Pool Stake
-
{String(validatorDetails.poolStake)}
+
{formatAmount(validatorDetails.poolStake, tokenInfo, false)}
Validator Stake
-
{String(validatorDetails.validatorStake)}
+
{formatAmount(validatorDetails.validatorStake, tokenInfo, false)}
Delegated Stake
-
{String(delegatedStake)}
+
{formatAmount(delegatedStake, tokenInfo, false)}
Fixed Cost
-
{Number(validatorDetails?.fixedCost)}
+
{formatAmount(validatorDetails?.fixedCost, manaInfo, false)}
Latest Supported Protocol Version
diff --git a/client/src/app/lib/interfaces/KeyValue.interfaces.ts b/client/src/app/lib/interfaces/KeyValue.interfaces.ts index 38f212608..3c001a799 100644 --- a/client/src/app/lib/interfaces/KeyValue.interfaces.ts +++ b/client/src/app/lib/interfaces/KeyValue.interfaces.ts @@ -1,7 +1,9 @@ +import { ReactNode } from "react"; + export interface IKeyValue { orientation?: "row" | "column"; label: string; - value: string | number | null | undefined; + value: string | number | null | ReactNode | undefined; } export interface IKeyValueEntries extends IKeyValue { diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index e8492aa00..663d481fd 100644 --- a/client/src/app/routes/nova/OutputPage.tsx +++ b/client/src/app/routes/nova/OutputPage.tsx @@ -59,6 +59,8 @@ const OutputPage: React.FC> = ({ const isSpent = spentSlotIndex !== null; const transactionIdSpent = spent?.transactionId ?? null; const outputIndex = computeOutputIndexFromOutputId(outputId); + const commitmentId = outputMetadataResponse?.included?.commitmentId; + const slotIndex = outputMetadataResponse?.included?.slot; let outputManaDetails: OutputManaDetails | null = null; if (output && createdSlotIndex && protocolInfo) { @@ -117,6 +119,15 @@ const OutputPage: React.FC> = ({
)} + {commitmentId && ( +
+
Commitment ID
+
+ +
+
+ )} + {outputIndex !== undefined && (
Output index
diff --git a/client/src/helpers/nova/manaUtils.ts b/client/src/helpers/nova/manaUtils.tsx similarity index 53% rename from client/src/helpers/nova/manaUtils.ts rename to client/src/helpers/nova/manaUtils.tsx index 205d84550..03f88f7e4 100644 --- a/client/src/helpers/nova/manaUtils.ts +++ b/client/src/helpers/nova/manaUtils.tsx @@ -1,5 +1,7 @@ -import { BasicOutput, Output, ProtocolParameters, Utils } from "@iota/sdk-wasm-nova/web"; +import { BaseTokenResponse, BasicOutput, Output, ProtocolParameters, Utils } from "@iota/sdk-wasm-nova/web"; import { IKeyValueEntries } from "~/app/lib/interfaces"; +import { formatAmount } from "../stardust/valueFormatHelper"; +import React from "react"; export interface OutputManaDetails { storedMana: string; @@ -22,7 +24,7 @@ export function buildManaDetailsForOutput( let totalMana = BigInt(decayedMana.stored) + BigInt(decayedMana.potential); if (manaRewards !== null) { - totalMana += manaRewards; + totalMana += BigInt(manaRewards); } return { @@ -34,30 +36,54 @@ export function buildManaDetailsForOutput( }; } -export function getManaKeyValueEntries(manaDetails: OutputManaDetails | null): IKeyValueEntries { +export function getManaKeyValueEntries( + manaDetails: OutputManaDetails | null, + manaInfo: BaseTokenResponse, + showManaRewards: boolean = false, +): IKeyValueEntries { const showDecayMana = manaDetails?.storedMana && manaDetails?.storedManaDecayed; const decay = showDecayMana ? Number(manaDetails?.storedMana ?? 0) - Number(manaDetails?.storedManaDecayed ?? 0) : undefined; - return { + const renderMana = (mana?: string | number | null): React.ReactNode => { + const [isFormatFull, setIsFormatFull] = React.useState(false); + return ( + { + setIsFormatFull(!isFormatFull); + e.stopPropagation(); + }} + > + {formatAmount(mana ?? 0, manaInfo, isFormatFull)} + + ); + }; + + const entries = { label: "Mana:", - value: manaDetails?.totalMana, + value: renderMana(manaDetails?.totalMana), entries: [ { label: "Stored:", - value: manaDetails?.storedMana, + value: renderMana(manaDetails?.storedMana), }, { label: "Decay:", - value: decay, + value: renderMana(decay), }, { label: "Potential:", - value: manaDetails?.potentialMana, - }, - { - label: "Mana Rewards:", - value: manaDetails?.manaRewards, + value: renderMana(manaDetails?.potentialMana), }, ], }; + + if (showManaRewards) { + entries.entries.push({ + label: "Mana Rewards:", + value: renderMana(manaDetails?.manaRewards), + }); + } + + return entries; } diff --git a/client/src/helpers/nova/nameHelper.ts b/client/src/helpers/nova/nameHelper.ts index 4b81e4096..2253b3dde 100644 --- a/client/src/helpers/nova/nameHelper.ts +++ b/client/src/helpers/nova/nameHelper.ts @@ -1,4 +1,4 @@ -import { PayloadType, UnlockType } from "@iota/sdk-wasm-nova/web"; +import { FeatureType, OutputType, PayloadType, UnlockType } from "@iota/sdk-wasm-nova/web"; export class NameHelper { /** @@ -60,4 +60,70 @@ export class NameHelper { return payloadType; } + + /** + * Get the name for the output type. + * @param type The type to get the name for. + * @returns The output type name. + */ + public static getOutputTypeName(type: OutputType): string { + switch (type) { + case OutputType.Basic: + return "Basic"; + case OutputType.Account: + return "Account"; + case OutputType.Anchor: + return "Anchor"; + case OutputType.Foundry: + return "Foundry"; + case OutputType.Nft: + return "Nft"; + case OutputType.Delegation: + return "Delegation"; + default: + return "Unknown Output"; + } + } + /** + * Get the name for the feature type. + * @param type The type to get the name for. + * @param isImmutable Whether the feature is immutable or not. + * @returns The feature type name. + */ + public static 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"; + } } diff --git a/client/src/helpers/stardust/valueFormatHelper.spec.ts b/client/src/helpers/stardust/valueFormatHelper.spec.ts index 5dd306211..949bb4a73 100644 --- a/client/src/helpers/stardust/valueFormatHelper.spec.ts +++ b/client/src/helpers/stardust/valueFormatHelper.spec.ts @@ -137,6 +137,10 @@ describe("formatAmount", () => { expect(formatAmount(1450896407249092n, tokenInfo)).toBe("1450896407.24 IOTA"); }); + test("should not break formatting a bigint over Number.MAX_SAFE_INTEGER", () => { + expect(formatAmount(9007199254740993n, tokenInfo)).toBe("9007199254.74 IOTA"); + }); + test("should honour format full (bigint)", () => { expect(formatAmount(1n, tokenInfo, true)).toBe("1 micro"); }); @@ -203,6 +207,10 @@ describe("formatAmount", () => { expect(formatAmount("1450896407249092", tokenInfo)).toBe("1450896407.24 IOTA"); }); + test("should not break formatting a bigint over Number.MAX_SAFE_INTEGER", () => { + expect(formatAmount("9007199254740993", tokenInfo)).toBe("9007199254.74 IOTA"); + }); + test("should honour format full (number)", () => { expect(formatAmount("1", tokenInfo, true)).toBe("1 micro"); }); @@ -224,5 +232,9 @@ describe("formatAmount", () => { test("should not break with String null", () => { expect(formatAmount(String(null), tokenInfo)).toBe(""); }); + + test("should not break with empty String", () => { + expect(formatAmount("", tokenInfo)).toBe(""); + }); }); }); diff --git a/client/src/helpers/stardust/valueFormatHelper.tsx b/client/src/helpers/stardust/valueFormatHelper.tsx index 02afc1dcb..55f2baf2d 100644 --- a/client/src/helpers/stardust/valueFormatHelper.tsx +++ b/client/src/helpers/stardust/valueFormatHelper.tsx @@ -23,11 +23,7 @@ export function formatAmount( decimalPlaces: number = 2, trailingDecimals?: boolean, ): string { - if (value === 0 || value === 0n) { - return `0 ${formatFull ? tokenInfo.subunit ?? tokenInfo.unit : tokenInfo.unit}`; - } - - if (!value || value === "null" || value === "undefined") { + if (value === null || value === undefined || value === "" || isNaN(Number(value))) { return ""; }