diff --git a/apps/core/src/utils/migration/createMigrationTransaction.ts b/apps/core/src/utils/migration/createMigrationTransaction.ts index 8519a950831..c5ac17fa556 100644 --- a/apps/core/src/utils/migration/createMigrationTransaction.ts +++ b/apps/core/src/utils/migration/createMigrationTransaction.ts @@ -1,7 +1,7 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { IotaClient, IotaObjectData } from '@iota/iota-sdk/client'; +import { DynamicFieldInfo, IotaClient, IotaObjectData } from '@iota/iota-sdk/client'; import { Transaction } from '@iota/iota-sdk/transactions'; import { STARDUST_PACKAGE_ID } from '../../constants/migration.constants'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; @@ -17,21 +17,28 @@ type NestedResultType = { NestedResult: [number, number]; }; -export async function getNativeTokenTypesFromBag( - bagId: string, - client: IotaClient, -): Promise { +export async function getNativeTokensFromBag(bagId: string, client: IotaClient) { const nativeTokenDynamicFields = await client.getDynamicFields({ parentId: bagId, }); - const nativeTokenTypes: string[] = []; + const nativeTokenTypes: DynamicFieldInfo[] = []; for (const nativeToken of nativeTokenDynamicFields.data) { - nativeTokenTypes.push(nativeToken?.name?.value as string); + nativeTokenTypes.push(nativeToken); } return nativeTokenTypes; } +export async function getNativeTokenTypesFromBag( + bagId: string, + client: IotaClient, +): Promise { + const nativeTokenDynamicFields = await client.getDynamicFields({ + parentId: bagId, + }); + return nativeTokenDynamicFields.data.map(({ name }) => name.value as string); +} + export function validateBasicOutputObject(outputObject: IotaObjectData): BasicOutputObject { if (outputObject.content?.dataType !== 'moveObject') { throw new Error('Invalid basic output object'); diff --git a/apps/core/src/utils/parseObjectDetails.ts b/apps/core/src/utils/parseObjectDetails.ts index 76a9884c5d8..fefa3c717e1 100644 --- a/apps/core/src/utils/parseObjectDetails.ts +++ b/apps/core/src/utils/parseObjectDetails.ts @@ -9,10 +9,16 @@ type ObjectChangeWithObjectType = Extract< { objectType: string } >; +type PackageId = string; +type ModuleName = string; +type TypeName = string; export function parseObjectChangeDetails( objectChange: ObjectChangeWithObjectType, -): [string, string, string] { - const [packageId, moduleName, typeName] = - objectChange.objectType?.split('<')[0]?.split('::') || []; - return [packageId, moduleName, typeName]; +): [PackageId, ModuleName, TypeName] { + return extractObjectTypeStruct(objectChange.objectType); +} + +export function extractObjectTypeStruct(objectType: string): [PackageId, ModuleName, TypeName] { + const [packageId, moduleName, functionName] = objectType?.split('<')[0]?.split('::') || []; + return [packageId, moduleName, functionName]; } diff --git a/apps/ui-kit/src/lib/components/atoms/index.ts b/apps/ui-kit/src/lib/components/atoms/index.ts index 373f611cd1e..12a58426391 100644 --- a/apps/ui-kit/src/lib/components/atoms/index.ts +++ b/apps/ui-kit/src/lib/components/atoms/index.ts @@ -19,3 +19,4 @@ export * from './snackbar'; export * from './visual-asset-card'; export * from './loading-indicator'; export * from './placeholder'; +export * from './skeleton'; diff --git a/apps/ui-kit/src/lib/components/atoms/skeleton/Skeleton.tsx b/apps/ui-kit/src/lib/components/atoms/skeleton/Skeleton.tsx new file mode 100644 index 00000000000..9504693f036 --- /dev/null +++ b/apps/ui-kit/src/lib/components/atoms/skeleton/Skeleton.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import cx from 'classnames'; + +interface SkeletonLoaderProps { + /** + * Width class for the skeleton div. + */ + widthClass?: string; + /** + * Height class for the skeleton div. + */ + heightClass?: string; + /** + * If true, the skeleton will use darker neutral colors. + */ + hasSecondaryColors?: boolean; + /** + * Whether the class `rounded-full` should be applied. Defaults to true. + */ + isRounded?: boolean; +} + +export function Skeleton({ + children, + widthClass = 'w-full', + heightClass = 'h-3', + hasSecondaryColors, + isRounded = true, +}: React.PropsWithChildren): React.JSX.Element { + return ( +
+ {children} +
+ ); +} diff --git a/apps/ui-kit/src/lib/components/atoms/skeleton/index.ts b/apps/ui-kit/src/lib/components/atoms/skeleton/index.ts new file mode 100644 index 00000000000..4540f454c5f --- /dev/null +++ b/apps/ui-kit/src/lib/components/atoms/skeleton/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from './Skeleton'; diff --git a/apps/ui-kit/src/storybook/stories/atoms/Skeleton.stories.tsx b/apps/ui-kit/src/storybook/stories/atoms/Skeleton.stories.tsx new file mode 100644 index 00000000000..cb611c8cf08 --- /dev/null +++ b/apps/ui-kit/src/storybook/stories/atoms/Skeleton.stories.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import type { Meta, StoryObj } from '@storybook/react'; +import { Card, CardImage, ImageShape, Skeleton } from '@/components'; + +const meta: Meta = { + component: Skeleton, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const SkeletonCard: Story = { + render: () => ( + + +
+ + +
+ + +
+
+ + +
+ + ), +}; diff --git a/apps/wallet-dashboard/app/(protected)/migrations/page.tsx b/apps/wallet-dashboard/app/(protected)/migrations/page.tsx index 544f352e963..acc5dd6f40c 100644 --- a/apps/wallet-dashboard/app/(protected)/migrations/page.tsx +++ b/apps/wallet-dashboard/app/(protected)/migrations/page.tsx @@ -1,10 +1,14 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 + 'use client'; +import { useState, useMemo, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import clsx from 'clsx'; import MigratePopup from '@/components/Popup/Popups/MigratePopup'; -import { usePopups } from '@/hooks'; -import { summarizeMigratableObjectValues } from '@/lib/utils'; +import { useGetStardustMigratableObjects, usePopups } from '@/hooks'; +import { summarizeMigratableObjectValues, summarizeUnmigratableObjectValues } from '@/lib/utils'; import { Button, ButtonSize, @@ -16,18 +20,12 @@ import { Panel, Title, } from '@iota/apps-ui-kit'; +import { Assets, Clock, IotaLogoMark, Tokens } from '@iota/ui-icons'; import { useCurrentAccount, useIotaClient } from '@iota/dapp-kit'; import { STARDUST_BASIC_OUTPUT_TYPE, STARDUST_NFT_OUTPUT_TYPE, useFormatCoin } from '@iota/core'; -import { useGetStardustMigratableObjects } from '@/hooks'; -import { useQueryClient } from '@tanstack/react-query'; -import { Assets, Clock, IotaLogoMark, Tokens } from '@iota/ui-icons'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; - -interface MigrationDisplayCard { - title: string; - subtitle: string; - icon: React.FC; -} +import { StardustOutputMigrationStatus } from '@/lib/enums'; +import { MigrationObjectsPanel } from '@/components'; function MigrationDashboardPage(): JSX.Element { const account = useCurrentAccount(); @@ -36,98 +34,128 @@ function MigrationDashboardPage(): JSX.Element { const queryClient = useQueryClient(); const iotaClient = useIotaClient(); + const [selectedStardustObjectsCategory, setSelectedStardustObjectsCategory] = useState< + StardustOutputMigrationStatus | undefined + >(undefined); + + const { data: stardustMigrationObjects, isPlaceholderData } = + useGetStardustMigratableObjects(address); const { migratableBasicOutputs, - unmigratableBasicOutputs, migratableNftOutputs, + unmigratableBasicOutputs, unmigratableNftOutputs, - } = useGetStardustMigratableObjects(address); + } = stardustMigrationObjects || {}; + + const { + totalIotaAmount, + totalNativeTokens: migratableNativeTokens, + totalVisualAssets: migratableVisualAssets, + } = summarizeMigratableObjectValues({ + basicOutputs: migratableBasicOutputs, + nftOutputs: migratableNftOutputs, + address, + }); + const { totalUnmigratableObjects } = summarizeUnmigratableObjectValues({ + basicOutputs: unmigratableBasicOutputs, + nftOutputs: unmigratableNftOutputs, + }); const hasMigratableObjects = - migratableBasicOutputs.length > 0 || migratableNftOutputs.length > 0; - - function handleOnSuccess(digest: string): void { - iotaClient - .waitForTransaction({ - digest, - }) - .then(() => { + (migratableBasicOutputs?.length || 0) > 0 && (migratableNftOutputs?.length || 0) > 0; + + const [timelockedIotaTokens, symbol] = useFormatCoin(totalIotaAmount, IOTA_TYPE_ARG); + + const handleOnSuccess = useCallback( + (digest: string) => { + iotaClient.waitForTransaction({ digest }).then(() => { queryClient.invalidateQueries({ queryKey: [ 'get-all-owned-objects', address, - { - StructType: STARDUST_BASIC_OUTPUT_TYPE, - }, + { StructType: STARDUST_BASIC_OUTPUT_TYPE }, ], }); queryClient.invalidateQueries({ queryKey: [ 'get-all-owned-objects', address, - { - StructType: STARDUST_NFT_OUTPUT_TYPE, - }, + { StructType: STARDUST_NFT_OUTPUT_TYPE }, ], }); }); - } - function openMigratePopup(): void { - openPopup( - , - ); - } - - const { - accumulatedIotaAmount: accumulatedTimelockedIotaAmount, - totalNativeTokens, - totalVisualAssets, - } = summarizeMigratableObjectValues({ - migratableBasicOutputs, - migratableNftOutputs, - address, - }); - - const [timelockedIotaTokens, symbol] = useFormatCoin( - accumulatedTimelockedIotaAmount, - IOTA_TYPE_ARG, + }, + [iotaClient, queryClient, address], ); - const MIGRATION_CARDS: MigrationDisplayCard[] = [ + const MIGRATION_CARDS: MigrationDisplayCardProps[] = [ { title: `${timelockedIotaTokens} ${symbol}`, subtitle: 'IOTA Tokens', icon: IotaLogoMark, }, { - title: `${totalNativeTokens}`, + title: `${migratableNativeTokens}`, subtitle: 'Native Tokens', icon: Tokens, }, { - title: `${totalVisualAssets}`, + title: `${migratableVisualAssets}`, subtitle: 'Visual Assets', icon: Assets, }, ]; - const timelockedAssetsAmount = unmigratableBasicOutputs.length + unmigratableNftOutputs.length; - const TIMELOCKED_ASSETS_CARDS: MigrationDisplayCard[] = [ + const TIMELOCKED_ASSETS_CARDS: MigrationDisplayCardProps[] = [ { - title: `${timelockedAssetsAmount}`, + title: `${totalUnmigratableObjects}`, subtitle: 'Time-locked', icon: Clock, }, ]; + const selectedObjects = useMemo(() => { + if (stardustMigrationObjects) { + if (selectedStardustObjectsCategory === StardustOutputMigrationStatus.Migratable) { + return [ + ...stardustMigrationObjects.migratableBasicOutputs, + ...stardustMigrationObjects.migratableNftOutputs, + ]; + } else if ( + selectedStardustObjectsCategory === StardustOutputMigrationStatus.TimeLocked + ) { + return [ + ...stardustMigrationObjects.unmigratableBasicOutputs, + ...stardustMigrationObjects.unmigratableNftOutputs, + ]; + } + } + return []; + }, [selectedStardustObjectsCategory, stardustMigrationObjects]); + + function openMigratePopup(): void { + openPopup( + , + ); + } + + function handleCloseDetailsPanel() { + setSelectedStardustObjectsCategory(undefined); + } + return (
-
+
<div className="flex flex-col gap-xs p-md--rs"> {MIGRATION_CARDS.map((card) => ( - <Card key={card.subtitle}> - <CardImage shape={ImageShape.SquareRounded}> - <card.icon /> - </CardImage> - <CardBody title={card.title} subtitle={card.subtitle} /> - </Card> + <MigrationDisplayCard + key={card.subtitle} + isPlaceholder={isPlaceholderData} + {...card} + /> ))} - <Button text="See All" type={ButtonType.Ghost} fullWidth /> + <Button + text="See All" + type={ButtonType.Ghost} + fullWidth + disabled={ + selectedStardustObjectsCategory === + StardustOutputMigrationStatus.Migratable || + !hasMigratableObjects + } + onClick={() => + setSelectedStardustObjectsCategory( + StardustOutputMigrationStatus.Migratable, + ) + } + /> </div> </Panel> @@ -158,20 +199,64 @@ function MigrationDashboardPage(): JSX.Element { <Title title="Time-locked Assets" /> <div className="flex flex-col gap-xs p-md--rs"> {TIMELOCKED_ASSETS_CARDS.map((card) => ( - <Card key={card.subtitle}> - <CardImage shape={ImageShape.SquareRounded}> - <card.icon /> - </CardImage> - <CardBody title={card.title} subtitle={card.subtitle} /> - </Card> + <MigrationDisplayCard + key={card.subtitle} + isPlaceholder={isPlaceholderData} + {...card} + /> ))} - <Button text="See All" type={ButtonType.Ghost} fullWidth /> + <Button + text="See All" + type={ButtonType.Ghost} + fullWidth + disabled={ + selectedStardustObjectsCategory === + StardustOutputMigrationStatus.TimeLocked || + !totalUnmigratableObjects + } + onClick={() => + setSelectedStardustObjectsCategory( + StardustOutputMigrationStatus.TimeLocked, + ) + } + /> </div> </Panel> </div> + + <MigrationObjectsPanel + selectedObjects={selectedObjects} + onClose={handleCloseDetailsPanel} + isTimelocked={ + selectedStardustObjectsCategory === StardustOutputMigrationStatus.TimeLocked + } + /> </div> </div> ); } +interface MigrationDisplayCardProps { + title: string; + subtitle: string; + icon: React.ComponentType; + isPlaceholder?: boolean; +} + +function MigrationDisplayCard({ + title, + subtitle, + icon: Icon, + isPlaceholder, +}: MigrationDisplayCardProps): React.JSX.Element { + return ( + <Card> + <CardImage shape={ImageShape.SquareRounded}> + <Icon /> + </CardImage> + <CardBody title={isPlaceholder ? '--' : title} subtitle={subtitle} /> + </Card> + ); +} + export default MigrationDashboardPage; diff --git a/apps/wallet-dashboard/components/MigrationOverview.tsx b/apps/wallet-dashboard/components/MigrationOverview.tsx index 9b17852f3d5..09faf91737b 100644 --- a/apps/wallet-dashboard/components/MigrationOverview.tsx +++ b/apps/wallet-dashboard/components/MigrationOverview.tsx @@ -12,7 +12,7 @@ export function MigrationOverview() { const router = useRouter(); const account = useCurrentAccount(); const address = account?.address || ''; - const { migratableBasicOutputs, migratableNftOutputs } = + const { data: { migratableBasicOutputs = [], migratableNftOutputs = [] } = {} } = useGetStardustMigratableObjects(address); const needsMigration = migratableBasicOutputs.length > 0 || migratableNftOutputs.length > 0; diff --git a/apps/wallet-dashboard/components/Popup/Popups/MigratePopup.tsx b/apps/wallet-dashboard/components/Popup/Popups/MigratePopup.tsx index 627fb860f3f..e824e41a24d 100644 --- a/apps/wallet-dashboard/components/Popup/Popups/MigratePopup.tsx +++ b/apps/wallet-dashboard/components/Popup/Popups/MigratePopup.tsx @@ -16,8 +16,8 @@ import { NotificationType } from '@/stores/notificationStore'; import { Loader, Warning } from '@iota/ui-icons'; interface MigratePopupProps { - basicOutputObjects: IotaObjectData[]; - nftOutputObjects: IotaObjectData[]; + basicOutputObjects: IotaObjectData[] | undefined; + nftOutputObjects: IotaObjectData[] | undefined; closePopup: () => void; onSuccess?: (digest: string) => void; } diff --git a/apps/wallet-dashboard/components/VirtualList.tsx b/apps/wallet-dashboard/components/VirtualList.tsx index 49178382bfc..54920f7d64c 100644 --- a/apps/wallet-dashboard/components/VirtualList.tsx +++ b/apps/wallet-dashboard/components/VirtualList.tsx @@ -5,6 +5,7 @@ import React, { ReactNode, useEffect } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; +import clsx from 'clsx'; interface VirtualListProps<T> { items: T[]; @@ -14,6 +15,8 @@ interface VirtualListProps<T> { estimateSize: (index: number) => number; render: (item: T, index: number) => ReactNode; onClick?: (item: T) => void; + heightClassName?: string; + overflowClassName?: string; } function VirtualList<T>({ @@ -24,6 +27,8 @@ function VirtualList<T>({ estimateSize, render, onClick, + heightClassName = 'h-full', + overflowClassName, }: VirtualListProps<T>): JSX.Element { const containerRef = React.useRef<HTMLDivElement | null>(null); const virtualizer = useVirtualizer({ @@ -61,7 +66,10 @@ function VirtualList<T>({ ]); return ( - <div className="relative h-full w-full" ref={containerRef}> + <div + className={clsx('relative w-full', heightClassName, overflowClassName)} + ref={containerRef} + > <div style={{ height: `${virtualizer.getTotalSize()}px`, @@ -69,27 +77,30 @@ function VirtualList<T>({ position: 'relative', }} > - {virtualItems.map((virtualItem) => ( - <div - key={virtualItem.key} - className={`absolute w-full ${onClick ? 'cursor-pointer' : ''}`} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: `${virtualItem.size}px`, - transform: `translateY(${virtualItem.start}px)`, - }} - onClick={() => onClick && onClick(items[virtualItem.index])} - > - {virtualItem.index > items.length - 1 - ? hasNextPage - ? 'Loading more...' - : 'Nothing more to load' - : render(items[virtualItem.index], virtualItem.index)} - </div> - ))} + {virtualItems.map((virtualItem) => { + const item = items[virtualItem.index]; + return ( + <div + key={virtualItem.key} + className={`absolute w-full ${onClick ? 'cursor-pointer' : ''}`} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)`, + }} + onClick={() => onClick && onClick(item)} + > + {virtualItem.index > items.length - 1 + ? hasNextPage + ? 'Loading more...' + : 'Nothing more to load' + : render(item, virtualItem.index)} + </div> + ); + })} </div> </div> ); diff --git a/apps/wallet-dashboard/components/index.ts b/apps/wallet-dashboard/components/index.ts index 37ff834be4f..1e655a49bdc 100644 --- a/apps/wallet-dashboard/components/index.ts +++ b/apps/wallet-dashboard/components/index.ts @@ -23,6 +23,7 @@ export * from './ExplorerLink'; export * from './Dialogs'; export * from './ValidatorStakingData'; export * from './tiles'; +export * from './migration'; export * from './Toaster'; export * from './Banner'; export * from './MigrationOverview'; diff --git a/apps/wallet-dashboard/components/migration/MigrationObjectsPanel.tsx b/apps/wallet-dashboard/components/migration/MigrationObjectsPanel.tsx new file mode 100644 index 00000000000..4d5d80d0af3 --- /dev/null +++ b/apps/wallet-dashboard/components/migration/MigrationObjectsPanel.tsx @@ -0,0 +1,141 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { useGroupedMigrationObjectsByExpirationDate } from '@/hooks'; +import { + STARDUST_MIGRATABLE_OBJECTS_FILTER_LIST, + STARDUST_UNMIGRATABLE_OBJECTS_FILTER_LIST, +} from '@/lib/constants'; +import { StardustOutputDetailsFilter } from '@/lib/enums'; +import { + Button, + ButtonType, + Card, + CardImage, + Chip, + ImageShape, + InfoBox, + InfoBoxStyle, + InfoBoxType, + Panel, + Skeleton, + Title, +} from '@iota/apps-ui-kit'; +import type { IotaObjectData } from '@iota/iota-sdk/client'; +import { Close, Warning } from '@iota/ui-icons'; +import clsx from 'clsx'; +import { useState } from 'react'; +import { MigrationObjectDetailsCard } from './migration-object-details-card'; +import VirtualList from '../VirtualList'; +import { filterMigrationObjects } from '@/lib/utils'; + +const FILTERS = { + migratable: STARDUST_MIGRATABLE_OBJECTS_FILTER_LIST, + unmigratable: STARDUST_UNMIGRATABLE_OBJECTS_FILTER_LIST, +}; + +interface MigrationObjectsPanelProps { + selectedObjects: IotaObjectData[]; + onClose: () => void; + isTimelocked: boolean; +} + +export function MigrationObjectsPanel({ + selectedObjects, + onClose, + isTimelocked, +}: MigrationObjectsPanelProps): React.JSX.Element { + const [stardustOutputDetailsFilter, setStardustOutputDetailsFilter] = + useState<StardustOutputDetailsFilter>(StardustOutputDetailsFilter.All); + + const { + data: resolvedObjects = [], + isLoading, + error: isErrored, + } = useGroupedMigrationObjectsByExpirationDate(selectedObjects, isTimelocked); + + const filteredObjects = filterMigrationObjects(resolvedObjects, stardustOutputDetailsFilter); + + const filters = isTimelocked ? FILTERS.unmigratable : FILTERS.migratable; + const isHidden = selectedObjects.length === 0; + + return ( + <div className={clsx('flex h-full min-h-0 w-2/3 flex-col', isHidden && 'hidden')}> + <Panel> + <Title + title="Details" + trailingElement={ + <Button icon={<Close />} type={ButtonType.Ghost} onClick={onClose} /> + } + /> + <div className="flex min-h-0 flex-1 flex-col px-md--rs"> + <div className="flex flex-row gap-xs py-xs"> + {filters.map((filter) => ( + <Chip + key={filter} + label={filter} + onClick={() => setStardustOutputDetailsFilter(filter)} + selected={stardustOutputDetailsFilter === filter} + /> + ))} + </div> + <div className="flex min-h-0 flex-col py-sm"> + <div className="h-full flex-1 overflow-auto"> + {isLoading && <LoadingPanel />} + {isErrored && !isLoading && ( + <div className="flex h-full max-h-full w-full flex-col items-center"> + <InfoBox + title="Error" + supportingText="Failed to load stardust objects" + style={InfoBoxStyle.Elevated} + type={InfoBoxType.Error} + icon={<Warning />} + /> + </div> + )} + {!isLoading && !isErrored && ( + <VirtualList + heightClassName="h-[600px]" + overflowClassName="overflow-y-auto" + items={filteredObjects} + estimateSize={() => 58} + render={(migrationObject) => ( + <MigrationObjectDetailsCard + migrationObject={migrationObject} + isTimelocked={isTimelocked} + /> + )} + /> + )} + </div> + </div> + </div> + </Panel> + </div> + ); +} + +function LoadingPanel() { + return ( + <div className="flex h-full max-h-full w-full flex-col overflow-hidden"> + {new Array(10).fill(0).map((_, index) => ( + <Card key={index}> + <CardImage shape={ImageShape.SquareRounded}> + <div className="h-10 w-10 animate-pulse bg-neutral-90 dark:bg-neutral-12" /> + <Skeleton widthClass="w-10" heightClass="h-10" isRounded={false} /> + </CardImage> + <div className="flex flex-col gap-y-xs"> + <Skeleton widthClass="w-40" heightClass="h-3.5" /> + <Skeleton widthClass="w-32" heightClass="h-3" hasSecondaryColors /> + </div> + <div className="ml-auto flex flex-col gap-y-xs"> + <Skeleton widthClass="w-20" heightClass="h-3.5" /> + <Skeleton widthClass="w-16" heightClass="h-3" hasSecondaryColors /> + </div> + </Card> + ))} + </div> + ); +} diff --git a/apps/wallet-dashboard/components/migration/index.ts b/apps/wallet-dashboard/components/migration/index.ts new file mode 100644 index 00000000000..cf43709989c --- /dev/null +++ b/apps/wallet-dashboard/components/migration/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from './MigrationObjectsPanel'; diff --git a/apps/wallet-dashboard/components/migration/migration-object-details-card/MigrationObjectDetailsCard.tsx b/apps/wallet-dashboard/components/migration/migration-object-details-card/MigrationObjectDetailsCard.tsx new file mode 100644 index 00000000000..599e58dbd21 --- /dev/null +++ b/apps/wallet-dashboard/components/migration/migration-object-details-card/MigrationObjectDetailsCard.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { ExternalImage } from '@/components'; +import { useGetCurrentEpochStartTimestamp } from '@/hooks'; +import { useGetCurrentEpochEndTimestamp } from '@/hooks/useGetCurrentEpochEndTimestamp'; +import { MIGRATION_OBJECT_WITHOUT_UC_KEY } from '@/lib/constants'; +import { CommonMigrationObjectType } from '@/lib/enums'; +import { ResolvedObjectTypes } from '@/lib/types'; +import { Card, CardBody, CardImage, ImageShape, LabelText, LabelTextSize } from '@iota/apps-ui-kit'; +import { MILLISECONDS_PER_SECOND, TimeUnit, useFormatCoin, useTimeAgo } from '@iota/core'; +import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; +import { Assets, DataStack, IotaLogoMark } from '@iota/ui-icons'; +import { useState } from 'react'; + +interface MigrationObjectDetailsCardProps { + migrationObject: ResolvedObjectTypes; + isTimelocked: boolean; +} +export function MigrationObjectDetailsCard({ + migrationObject: { unlockConditionTimestamp, ...migrationObject }, + isTimelocked: isTimelocked, +}: MigrationObjectDetailsCardProps) { + const coinType = 'coinType' in migrationObject ? migrationObject.coinType : IOTA_TYPE_ARG; + const [balance, token] = useFormatCoin(migrationObject.balance, coinType); + + switch (migrationObject.commonObjectType) { + case CommonMigrationObjectType.Basic: + return ( + <MigrationObjectCard + title={`${balance} ${token}`} + subtitle="IOTA Tokens" + unlockConditionTimestamp={unlockConditionTimestamp} + image={<IotaLogoMark />} + isTimelocked={isTimelocked} + /> + ); + case CommonMigrationObjectType.Nft: + return ( + <MigrationObjectCard + title={migrationObject.name} + subtitle="Visual Assets" + unlockConditionTimestamp={unlockConditionTimestamp} + image={ + <ExternalImageWithFallback + src={migrationObject.image_url} + alt={migrationObject.name} + fallback={<Assets />} + /> + } + isTimelocked={isTimelocked} + /> + ); + case CommonMigrationObjectType.NativeToken: + return ( + <MigrationObjectCard + isTimelocked={isTimelocked} + title={`${balance} ${token}`} + subtitle="Native Tokens" + unlockConditionTimestamp={unlockConditionTimestamp} + image={<DataStack />} + /> + ); + default: + return null; + } +} + +interface ExternalImageWithFallbackProps { + src: string; + alt: string; + fallback: React.ReactNode; +} +function ExternalImageWithFallback({ src, alt, fallback }: ExternalImageWithFallbackProps) { + const [errored, setErrored] = useState(false); + function handleError() { + setErrored(true); + } + return !errored ? <ExternalImage src={src} alt={alt} onError={handleError} /> : fallback; +} + +interface MigrationObjectCardProps { + title: string; + subtitle: string; + unlockConditionTimestamp: string; + isTimelocked: boolean; + image?: React.ReactNode; +} + +function MigrationObjectCard({ + title, + subtitle, + unlockConditionTimestamp, + isTimelocked, + image, +}: MigrationObjectCardProps) { + const hasUnlockConditionTimestamp = + unlockConditionTimestamp !== MIGRATION_OBJECT_WITHOUT_UC_KEY; + return ( + <Card> + <CardImage shape={ImageShape.SquareRounded}>{image}</CardImage> + <CardBody title={title} subtitle={subtitle} /> + {hasUnlockConditionTimestamp && ( + <UnlockConditionLabel + groupKey={unlockConditionTimestamp} + isTimelocked={isTimelocked} + /> + )} + </Card> + ); +} + +interface UnlockConditionLabelProps { + groupKey: string; + isTimelocked: boolean; +} +function UnlockConditionLabel({ groupKey, isTimelocked: isTimelocked }: UnlockConditionLabelProps) { + const { data: currentEpochStartTimestampMs, isLoading: isLoadingEpochStart } = + useGetCurrentEpochStartTimestamp(); + const { data: currentEpochEndTimestampMs, isLoading: isLoadingEpochEnd } = + useGetCurrentEpochEndTimestamp(); + + const epochEndMs = currentEpochEndTimestampMs ?? 0; + const epochStartMs = currentEpochStartTimestampMs ?? '0'; + const currentDateMs = Date.now(); + + const unlockConditionTimestampMs = parseInt(groupKey) * MILLISECONDS_PER_SECOND; + const isFromPreviousEpoch = + !isLoadingEpochStart && unlockConditionTimestampMs < parseInt(epochStartMs); + // TODO: https://github.com/iotaledger/iota/issues/4369 + const isInAFutureEpoch = !isLoadingEpochEnd && unlockConditionTimestampMs > epochEndMs; + + const outputTimestampMs = isInAFutureEpoch ? unlockConditionTimestampMs : epochEndMs; + + const timeLabel = useTimeAgo({ + timeFrom: outputTimestampMs, + shortedTimeLabel: true, + shouldEnd: true, + maxTimeUnit: TimeUnit.ONE_DAY, + }); + + const showLabel = !isFromPreviousEpoch && outputTimestampMs > currentDateMs; + + return ( + <div className="ml-auto h-full whitespace-nowrap"> + {showLabel && ( + <LabelText + size={LabelTextSize.Small} + text={timeLabel} + label={isTimelocked ? 'Unlocks in' : 'Expires in'} + /> + )} + </div> + ); +} diff --git a/apps/wallet-dashboard/components/migration/migration-object-details-card/index.ts b/apps/wallet-dashboard/components/migration/migration-object-details-card/index.ts new file mode 100644 index 00000000000..d7097a997c2 --- /dev/null +++ b/apps/wallet-dashboard/components/migration/migration-object-details-card/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from './MigrationObjectDetailsCard'; diff --git a/apps/wallet-dashboard/hooks/index.ts b/apps/wallet-dashboard/hooks/index.ts index 77ea2304aa4..932b903fdba 100644 --- a/apps/wallet-dashboard/hooks/index.ts +++ b/apps/wallet-dashboard/hooks/index.ts @@ -11,3 +11,4 @@ export * from './useGetCurrentEpochStartTimestamp'; export * from './useTimelockedUnstakeTransaction'; export * from './useExplorerLinkGetter'; export * from './useGetStardustMigratableObjects'; +export * from './useGroupedMigrationObjectsByExpirationDate'; diff --git a/apps/wallet-dashboard/hooks/useGetCurrentEpochEndTimestamp.ts b/apps/wallet-dashboard/hooks/useGetCurrentEpochEndTimestamp.ts new file mode 100644 index 00000000000..69217c4becf --- /dev/null +++ b/apps/wallet-dashboard/hooks/useGetCurrentEpochEndTimestamp.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { useIotaClient } from '@iota/dapp-kit'; +import { useQuery } from '@tanstack/react-query'; + +export function useGetCurrentEpochEndTimestamp() { + const client = useIotaClient(); + return useQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: ['current-epoch-end-timestamp'], + queryFn: async () => { + const iotaSystemState = await client.getLatestIotaSystemState(); + const epochStart = parseInt(iotaSystemState.epochStartTimestampMs); + const epochDuration = parseInt(iotaSystemState.epochDurationMs); + const epochEnd = epochStart + epochDuration; + return epochEnd; + }, + staleTime: 10 * 60 * 1000, // 10 minutes + }); +} diff --git a/apps/wallet-dashboard/hooks/useGetStardustMigratableObjects.ts b/apps/wallet-dashboard/hooks/useGetStardustMigratableObjects.ts index 7a5b40ce140..8480e9d9e2a 100644 --- a/apps/wallet-dashboard/hooks/useGetStardustMigratableObjects.ts +++ b/apps/wallet-dashboard/hooks/useGetStardustMigratableObjects.ts @@ -1,21 +1,17 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +import { useQuery } from '@tanstack/react-query'; import { useGetCurrentEpochStartTimestamp } from '@/hooks'; import { groupStardustObjectsByMigrationStatus } from '@/lib/utils'; import { STARDUST_BASIC_OUTPUT_TYPE, STARDUST_NFT_OUTPUT_TYPE, + TimeUnit, useGetAllOwnedObjects, } from '@iota/core'; -import { IotaObjectData } from '@iota/iota-sdk/client'; -export function useGetStardustMigratableObjects(address: string): { - migratableBasicOutputs: IotaObjectData[]; - unmigratableBasicOutputs: IotaObjectData[]; - migratableNftOutputs: IotaObjectData[]; - unmigratableNftOutputs: IotaObjectData[]; -} { +export function useGetStardustMigratableObjects(address: string) { const { data: currentEpochMs } = useGetCurrentEpochStartTimestamp(); const { data: basicOutputObjects } = useGetAllOwnedObjects(address, { StructType: STARDUST_BASIC_OUTPUT_TYPE, @@ -24,24 +20,41 @@ export function useGetStardustMigratableObjects(address: string): { StructType: STARDUST_NFT_OUTPUT_TYPE, }); - const { migratable: migratableBasicOutputs, unmigratable: unmigratableBasicOutputs } = - groupStardustObjectsByMigrationStatus( - basicOutputObjects ?? [], - Number(currentEpochMs), + return useQuery({ + queryKey: [ + 'stardust-migratable-objects', address, - ); + currentEpochMs, + basicOutputObjects, + nftOutputObjects, + ], + queryFn: () => { + const epochMs = Number(currentEpochMs) || 0; - const { migratable: migratableNftOutputs, unmigratable: unmigratableNftOutputs } = - groupStardustObjectsByMigrationStatus( - nftOutputObjects ?? [], - Number(currentEpochMs), - address, - ); + const { migratable: migratableBasicOutputs, unmigratable: unmigratableBasicOutputs } = + groupStardustObjectsByMigrationStatus(basicOutputObjects ?? [], epochMs, address); + + const { migratable: migratableNftOutputs, unmigratable: unmigratableNftOutputs } = + groupStardustObjectsByMigrationStatus(nftOutputObjects ?? [], epochMs, address); - return { - migratableBasicOutputs, - unmigratableBasicOutputs, - migratableNftOutputs, - unmigratableNftOutputs, - }; + return { + migratableBasicOutputs, + unmigratableBasicOutputs, + migratableNftOutputs, + unmigratableNftOutputs, + }; + }, + enabled: + !!address && + currentEpochMs !== undefined && + basicOutputObjects !== undefined && + nftOutputObjects !== undefined, + staleTime: TimeUnit.ONE_SECOND * TimeUnit.ONE_MINUTE * 5, + placeholderData: { + migratableBasicOutputs: [], + unmigratableBasicOutputs: [], + migratableNftOutputs: [], + unmigratableNftOutputs: [], + }, + }); } diff --git a/apps/wallet-dashboard/hooks/useGroupedMigrationObjectsByExpirationDate.ts b/apps/wallet-dashboard/hooks/useGroupedMigrationObjectsByExpirationDate.ts new file mode 100644 index 00000000000..d9d96816fd2 --- /dev/null +++ b/apps/wallet-dashboard/hooks/useGroupedMigrationObjectsByExpirationDate.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { ResolvedObjectTypes } from '@/lib/types'; +import { + groupMigrationObjectsByUnlockCondition, + sortStardustResolvedObjectsByExpiration, +} from '@/lib/utils'; +import { TimeUnit } from '@iota/core'; +import { useCurrentAccount, useIotaClient } from '@iota/dapp-kit'; +import { IotaObjectData } from '@iota/iota-sdk/client'; +import { useQuery } from '@tanstack/react-query'; +import { useGetCurrentEpochStartTimestamp } from './useGetCurrentEpochStartTimestamp'; +import { useGetCurrentEpochEndTimestamp } from './useGetCurrentEpochEndTimestamp'; + +export function useGroupedMigrationObjectsByExpirationDate( + objects: IotaObjectData[], + isTimelockUnlockCondition: boolean = false, +) { + const client = useIotaClient(); + const address = useCurrentAccount()?.address; + + const { data: currentEpochStartTimestampMs } = useGetCurrentEpochStartTimestamp(); + const { data: currentEpochEndTimestampMs } = useGetCurrentEpochEndTimestamp(); + + const epochStartMs = currentEpochStartTimestampMs ? parseInt(currentEpochStartTimestampMs) : 0; + const epochEndMs = currentEpochEndTimestampMs ? currentEpochEndTimestampMs : 0; + + return useQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: [ + 'grouped-migration-objects', + objects, + address, + isTimelockUnlockCondition, + epochStartMs, + epochEndMs, + ], + queryFn: async (): Promise<ResolvedObjectTypes[]> => { + if (!client || objects.length === 0) { + return []; + } + const resolvedObjects = await groupMigrationObjectsByUnlockCondition( + objects, + client, + address, + isTimelockUnlockCondition, + ); + + return sortStardustResolvedObjectsByExpiration( + resolvedObjects, + epochStartMs, + epochEndMs, + ); + }, + enabled: !!client && objects.length > 0, + staleTime: TimeUnit.ONE_SECOND * TimeUnit.ONE_MINUTE * 5, + }); +} diff --git a/apps/wallet-dashboard/lib/constants/index.ts b/apps/wallet-dashboard/lib/constants/index.ts index b2c6ab5f6cd..30680459cef 100644 --- a/apps/wallet-dashboard/lib/constants/index.ts +++ b/apps/wallet-dashboard/lib/constants/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from './vesting.constants'; +export * from './migration.constants'; diff --git a/apps/wallet-dashboard/lib/constants/migration.constants.ts b/apps/wallet-dashboard/lib/constants/migration.constants.ts new file mode 100644 index 00000000000..5aa2c5a5c0f --- /dev/null +++ b/apps/wallet-dashboard/lib/constants/migration.constants.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { StardustOutputDetailsFilter } from '../enums'; + +export const STARDUST_MIGRATABLE_OBJECTS_FILTER_LIST: StardustOutputDetailsFilter[] = Object.values( + StardustOutputDetailsFilter, +); + +export const STARDUST_UNMIGRATABLE_OBJECTS_FILTER_LIST: StardustOutputDetailsFilter[] = + Object.values(StardustOutputDetailsFilter).filter( + (element) => element !== StardustOutputDetailsFilter.WithExpiration, + ); + +export const MIGRATION_OBJECT_WITHOUT_UC_KEY = 'no-unlock-condition-timestamp'; diff --git a/apps/wallet-dashboard/lib/enums/commonMigrationObjectType.enums.ts b/apps/wallet-dashboard/lib/enums/commonMigrationObjectType.enums.ts new file mode 100644 index 00000000000..9395fca36b8 --- /dev/null +++ b/apps/wallet-dashboard/lib/enums/commonMigrationObjectType.enums.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export enum CommonMigrationObjectType { + NativeToken = 'nativeToken', + Nft = 'nft', + Basic = 'basic', +} diff --git a/apps/wallet-dashboard/lib/enums/index.ts b/apps/wallet-dashboard/lib/enums/index.ts index 87f3b0e2f22..6d936d1ec45 100644 --- a/apps/wallet-dashboard/lib/enums/index.ts +++ b/apps/wallet-dashboard/lib/enums/index.ts @@ -3,3 +3,6 @@ export * from './protectedRouteTitle.enum'; export * from './assetCategory.enum'; +export * from './commonMigrationObjectType.enums'; +export * from './stardustOutputDetailsFilter.enum'; +export * from './stardustOutputMigrationStatus.enum'; diff --git a/apps/wallet-dashboard/lib/enums/stardustOutputDetailsFilter.enum.ts b/apps/wallet-dashboard/lib/enums/stardustOutputDetailsFilter.enum.ts new file mode 100644 index 00000000000..f1ff813072c --- /dev/null +++ b/apps/wallet-dashboard/lib/enums/stardustOutputDetailsFilter.enum.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export enum StardustOutputDetailsFilter { + All = 'All', + IOTA = 'IOTA', + NativeTokens = 'Native Tokens', + VisualAssets = 'Visual Assets', + WithExpiration = 'With Expiration', +} diff --git a/apps/wallet-dashboard/lib/enums/stardustOutputMigrationStatus.enum.ts b/apps/wallet-dashboard/lib/enums/stardustOutputMigrationStatus.enum.ts new file mode 100644 index 00000000000..29df88def12 --- /dev/null +++ b/apps/wallet-dashboard/lib/enums/stardustOutputMigrationStatus.enum.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export enum StardustOutputMigrationStatus { + Migratable = 'Migratable', + TimeLocked = 'TimeLocked', +} diff --git a/apps/wallet-dashboard/lib/types/index.ts b/apps/wallet-dashboard/lib/types/index.ts new file mode 100644 index 00000000000..cdec0f4219f --- /dev/null +++ b/apps/wallet-dashboard/lib/types/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from './stardustMigrationObjects'; diff --git a/apps/wallet-dashboard/lib/types/stardustMigrationObjects.ts b/apps/wallet-dashboard/lib/types/stardustMigrationObjects.ts new file mode 100644 index 00000000000..516cc7e2908 --- /dev/null +++ b/apps/wallet-dashboard/lib/types/stardustMigrationObjects.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { IotaObjectData } from '@iota/iota-sdk/client'; +import { CommonMigrationObjectType } from '../enums'; + +export type UnlockConditionTimestamp = string; + +interface CommonExpirationTypeObject { + unlockConditionTimestamp: UnlockConditionTimestamp; + output: IotaObjectData; + uniqueId: string; + balance: bigint; +} + +export interface ResolvedNativeToken extends CommonExpirationTypeObject { + name: string; + commonObjectType: CommonMigrationObjectType.NativeToken; + coinType: string; +} + +export interface ResolvedBasicObject extends CommonExpirationTypeObject { + type: string; + commonObjectType: CommonMigrationObjectType.Basic; +} + +export interface ResolvedNftObject extends CommonExpirationTypeObject { + name: string; + image_url: string; + commonObjectType: CommonMigrationObjectType.Nft; +} + +export type ResolvedObjectTypes = ResolvedBasicObject | ResolvedNftObject | ResolvedNativeToken; diff --git a/apps/wallet-dashboard/lib/utils/migration/filterMigrationObjectDetails.ts b/apps/wallet-dashboard/lib/utils/migration/filterMigrationObjectDetails.ts new file mode 100644 index 00000000000..3f9d54de6c8 --- /dev/null +++ b/apps/wallet-dashboard/lib/utils/migration/filterMigrationObjectDetails.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { MIGRATION_OBJECT_WITHOUT_UC_KEY } from '@/lib/constants'; +import { CommonMigrationObjectType, StardustOutputDetailsFilter } from '@/lib/enums'; +import { ResolvedObjectTypes } from '@/lib/types'; + +export function filterMigrationObjects( + objects: ResolvedObjectTypes[], + filter: StardustOutputDetailsFilter, +) { + switch (filter) { + case StardustOutputDetailsFilter.All: + return objects; + case StardustOutputDetailsFilter.IOTA: + return filterObjectByCommonOutputType(objects, CommonMigrationObjectType.Basic); + case StardustOutputDetailsFilter.VisualAssets: + return filterObjectByCommonOutputType(objects, CommonMigrationObjectType.Nft); + case StardustOutputDetailsFilter.NativeTokens: + return filterObjectByCommonOutputType(objects, CommonMigrationObjectType.NativeToken); + case StardustOutputDetailsFilter.WithExpiration: + return filterObjectsByExpiration(objects); + } +} + +function filterObjectByCommonOutputType( + objects: ResolvedObjectTypes[], + type: CommonMigrationObjectType, +) { + return objects.filter((object) => object.commonObjectType === type); +} + +function filterObjectsByExpiration(objects: ResolvedObjectTypes[]): ResolvedObjectTypes[] { + return objects.filter( + (object) => object.unlockConditionTimestamp !== MIGRATION_OBJECT_WITHOUT_UC_KEY, + ); +} diff --git a/apps/wallet-dashboard/lib/utils/migration/groupMigrationObjects.ts b/apps/wallet-dashboard/lib/utils/migration/groupMigrationObjects.ts new file mode 100644 index 00000000000..98badabacdd --- /dev/null +++ b/apps/wallet-dashboard/lib/utils/migration/groupMigrationObjects.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CommonMigrationObjectType } from '@/lib/enums'; +import { + ResolvedBasicObject, + ResolvedNativeToken, + ResolvedNftObject, + ResolvedObjectTypes, + UnlockConditionTimestamp, +} from '@/lib/types'; +import { + extractObjectTypeStruct, + getNativeTokensFromBag, + MILLISECONDS_PER_SECOND, + STARDUST_BASIC_OUTPUT_TYPE, + STARDUST_NFT_OUTPUT_TYPE, +} from '@iota/core'; +import { extractMigrationOutputFields, extractStorageDepositReturnAmount } from '.'; +import { IotaClient, IotaObjectData } from '@iota/iota-sdk/client'; +import { MIGRATION_OBJECT_WITHOUT_UC_KEY } from '@/lib/constants'; + +export async function groupMigrationObjectsByUnlockCondition( + objectsData: IotaObjectData[], + client: IotaClient, + currentAddress: string = '', + isTimelockUnlockCondition: boolean = false, +): Promise<ResolvedObjectTypes[]> { + const flatObjects: ResolvedObjectTypes[] = []; + const basicObjectMap: Map<string, ResolvedBasicObject> = new Map(); + const nativeTokenMap: Map<string, Map<string, ResolvedNativeToken>> = new Map(); + + const PROMISE_CHUNK_SIZE = 100; + + // Get output data in chunks of 100 + for (let i = 0; i < objectsData.length; i += PROMISE_CHUNK_SIZE) { + const chunk = objectsData.slice(i, i + PROMISE_CHUNK_SIZE); + + const promises = chunk.map(async (object) => { + const objectFields = extractMigrationOutputFields(object); + + let groupKey: string | undefined; + if (isTimelockUnlockCondition) { + const timestamp = objectFields.timelock_uc?.fields.unix_time.toString(); + groupKey = timestamp; + } else { + const timestamp = objectFields.expiration_uc?.fields.unix_time.toString(); + // Timestamp can be undefined if the object was timelocked and is now unlocked + // and it doesn't have an expiration unlock condition + groupKey = timestamp ?? MIGRATION_OBJECT_WITHOUT_UC_KEY; + } + + if (!groupKey) { + return; + } + + if (object.type === STARDUST_BASIC_OUTPUT_TYPE) { + const existing = basicObjectMap.get(groupKey); + const gasReturn = extractStorageDepositReturnAmount(objectFields, currentAddress); + const newBalance = + (existing ? existing.balance : 0n) + + BigInt(objectFields.balance) + + (gasReturn ?? 0n); + + if (existing) { + existing.balance = newBalance; + } else { + const newBasicObject: ResolvedBasicObject = { + balance: newBalance, + unlockConditionTimestamp: groupKey, + type: object.type, + commonObjectType: CommonMigrationObjectType.Basic, + output: object, + uniqueId: objectFields.id.id, + }; + basicObjectMap.set(groupKey, newBasicObject); + flatObjects.push(newBasicObject); + } + } else if (object.type === STARDUST_NFT_OUTPUT_TYPE) { + const nftDetails = await getNftDetails(object, groupKey, client); + flatObjects.push(...nftDetails); + } + + if (!nativeTokenMap.has(groupKey)) { + nativeTokenMap.set(groupKey, new Map()); + } + + const tokenGroup = nativeTokenMap.get(groupKey)!; + const objectNativeTokens = await extractNativeTokensFromObject( + object, + client, + groupKey, + ); + + for (const token of objectNativeTokens) { + const existing = tokenGroup.get(token.name); + + if (existing) { + existing.balance += token.balance; + } else { + tokenGroup.set(token.name, token); + flatObjects.push(token); + } + } + }); + + // Wait for all promises in the chunk to resolve + await Promise.all(promises); + } + + return flatObjects; +} + +export function sortStardustResolvedObjectsByExpiration( + objects: ResolvedObjectTypes[], + currentEpochStartMs: number, + currentEpochEndMs: number, +): ResolvedObjectTypes[] { + const currentTimestampMs = Date.now(); + + return objects.sort((a, b) => { + const aIsNoExpiration = a.unlockConditionTimestamp === MIGRATION_OBJECT_WITHOUT_UC_KEY; + const bIsNoExpiration = b.unlockConditionTimestamp === MIGRATION_OBJECT_WITHOUT_UC_KEY; + + // No-expiration objects should be last + if (aIsNoExpiration && bIsNoExpiration) return 0; + if (aIsNoExpiration) return 1; + if (bIsNoExpiration) return -1; + + const aTimestampMs = parseInt(a.unlockConditionTimestamp) * MILLISECONDS_PER_SECOND; + const bTimestampMs = parseInt(b.unlockConditionTimestamp) * MILLISECONDS_PER_SECOND; + + const aIsFromPreviousEpoch = aTimestampMs < currentEpochStartMs; + const bIsFromPreviousEpoch = bTimestampMs < currentEpochStartMs; + + // Objects from a past epoch should be last (but before no-expiration objects) + if (aIsFromPreviousEpoch && bIsFromPreviousEpoch) return 0; + if (aIsFromPreviousEpoch) return 1; + if (bIsFromPreviousEpoch) return -1; + + const aIsInFutureEpoch = aTimestampMs > currentEpochEndMs; + const bIsInFutureEpoch = bTimestampMs > currentEpochEndMs; + + const aOutputTimestampMs = aIsInFutureEpoch ? aTimestampMs : currentEpochEndMs; + const bOutputTimestampMs = bIsInFutureEpoch ? bTimestampMs : currentEpochEndMs; + + // Objects closer to the calculated `outputTimestampMs` should be first + const aProximity = Math.abs(aOutputTimestampMs - currentTimestampMs); + const bProximity = Math.abs(bOutputTimestampMs - currentTimestampMs); + + return aProximity - bProximity; + }); +} + +async function getNftDetails( + object: IotaObjectData, + expirationKey: UnlockConditionTimestamp, + client: IotaClient, +): Promise<ResolvedNftObject[]> { + const objectFields = extractMigrationOutputFields(object); + const nftOutputDynamicFields = await client.getDynamicFields({ + parentId: objectFields.id.id, + }); + + const nftDetails: ResolvedNftObject[] = []; + for (const nft of nftOutputDynamicFields.data) { + const nftObject = await client.getObject({ + id: nft.objectId, + options: { showDisplay: true }, + }); + + if (!nftObject?.data?.display?.data) { + continue; + } + + nftDetails.push({ + balance: BigInt(objectFields.balance), + name: nftObject.data.display.data.name ?? '', + image_url: nftObject.data.display.data.image_url ?? '', + commonObjectType: CommonMigrationObjectType.Nft, + unlockConditionTimestamp: expirationKey, + output: object, + uniqueId: nftObject.data.objectId, + }); + } + + return nftDetails; +} + +async function extractNativeTokensFromObject( + object: IotaObjectData, + client: IotaClient, + expirationKey: UnlockConditionTimestamp, +): Promise<ResolvedNativeToken[]> { + const fields = extractMigrationOutputFields(object); + const bagId = fields.native_tokens.fields.id.id; + const bagSize = Number(fields.native_tokens.fields.size); + + const nativeTokens = bagSize > 0 ? await getNativeTokensFromBag(bagId, client) : []; + const result: ResolvedNativeToken[] = []; + + for (const nativeToken of nativeTokens) { + const nativeTokenParentId = fields.native_tokens.fields.id.id; + const objectDynamic = await client.getDynamicFieldObject({ + parentId: nativeTokenParentId, + name: nativeToken.name, + }); + + if (objectDynamic?.data?.content && 'fields' in objectDynamic.data.content) { + const nativeTokenFields = objectDynamic.data.content.fields as { + name: string; + value: string; + id: { id: string }; + }; + const tokenStruct = extractObjectTypeStruct(nativeTokenFields.name); + const tokenName = tokenStruct[2]; + const balance = BigInt(nativeTokenFields.value); + + result.push({ + name: tokenName, + balance, + coinType: nativeTokenFields.name, + unlockConditionTimestamp: expirationKey, + commonObjectType: CommonMigrationObjectType.NativeToken, + output: object, + uniqueId: nativeTokenFields.id.id, + }); + } + } + + return result; +} diff --git a/apps/wallet-dashboard/lib/utils/migration.ts b/apps/wallet-dashboard/lib/utils/migration/groupStardustObjectsByMigrationStatus.ts similarity index 53% rename from apps/wallet-dashboard/lib/utils/migration.ts rename to apps/wallet-dashboard/lib/utils/migration/groupStardustObjectsByMigrationStatus.ts index ee129d4d211..c7aa8f0e784 100644 --- a/apps/wallet-dashboard/lib/utils/migration.ts +++ b/apps/wallet-dashboard/lib/utils/migration/groupStardustObjectsByMigrationStatus.ts @@ -1,7 +1,7 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CommonOutputObjectWithUc } from '@iota/core'; +import { CommonOutputObjectWithUc, MILLISECONDS_PER_SECOND } from '@iota/core'; import { IotaObjectData } from '@iota/iota-sdk/client'; export type StardustMigrationGroupedObjects = { @@ -11,16 +11,16 @@ export type StardustMigrationGroupedObjects = { export function groupStardustObjectsByMigrationStatus( stardustOutputObjects: IotaObjectData[], - epochTimestamp: number, + epochTimestampMs: number, address: string, ): StardustMigrationGroupedObjects { const migratable: IotaObjectData[] = []; const unmigratable: IotaObjectData[] = []; - const epochUnix = epochTimestamp / 1000; + const epochUnix = epochTimestampMs / MILLISECONDS_PER_SECOND; for (const outputObject of stardustOutputObjects) { - const outputObjectFields = extractOutputFields(outputObject); + const outputObjectFields = extractMigrationOutputFields(outputObject); if (outputObjectFields.expiration_uc) { const unlockableAddress = @@ -33,6 +33,7 @@ export function groupStardustObjectsByMigrationStatus( continue; } } + if ( outputObjectFields.timelock_uc && outputObjectFields.timelock_uc.fields.unix_time > epochUnix @@ -50,49 +51,76 @@ export function groupStardustObjectsByMigrationStatus( interface MigratableObjectsData { totalNativeTokens: number; totalVisualAssets: number; - accumulatedIotaAmount: number; + totalIotaAmount: bigint; +} + +interface SummarizeMigrationObjectParams { + basicOutputs: IotaObjectData[] | undefined; + nftOutputs: IotaObjectData[] | undefined; + address: string; } export function summarizeMigratableObjectValues({ - migratableBasicOutputs, - migratableNftOutputs, + basicOutputs = [], + nftOutputs = [], address, -}: { - migratableBasicOutputs: IotaObjectData[]; - migratableNftOutputs: IotaObjectData[]; - address: string; -}): MigratableObjectsData { +}: SummarizeMigrationObjectParams): MigratableObjectsData { let totalNativeTokens = 0; - let totalIotaAmount = 0; + let totalIotaAmount: bigint = 0n; - const totalVisualAssets = migratableNftOutputs.length; - const outputObjects = [...migratableBasicOutputs, ...migratableNftOutputs]; + const totalVisualAssets = nftOutputs.length; + const outputObjects = [...basicOutputs, ...nftOutputs]; for (const output of outputObjects) { - const outputObjectFields = extractOutputFields(output); + const outputObjectFields = extractMigrationOutputFields(output); - totalIotaAmount += parseInt(outputObjectFields.balance); + totalIotaAmount += BigInt(outputObjectFields.balance); totalNativeTokens += parseInt(outputObjectFields.native_tokens.fields.size); - totalIotaAmount += extractStorageDepositReturnAmount(outputObjectFields, address) || 0; + totalIotaAmount += extractStorageDepositReturnAmount(outputObjectFields, address) || 0n; + } + + return { totalNativeTokens, totalVisualAssets, totalIotaAmount }; +} + +interface UnmmigratableObjectsData { + totalUnmigratableObjects: number; +} + +export function summarizeUnmigratableObjectValues({ + basicOutputs = [], + nftOutputs = [], +}: Omit<SummarizeMigrationObjectParams, 'address'>): UnmmigratableObjectsData { + const basicObjects = basicOutputs.length; + const nftObjects = nftOutputs.length; + let nativeTokens = 0; + + for (const output of [...basicOutputs, ...nftOutputs]) { + const outputObjectFields = extractMigrationOutputFields(output); + + nativeTokens += parseInt(outputObjectFields.native_tokens.fields.size); } - return { totalNativeTokens, totalVisualAssets, accumulatedIotaAmount: totalIotaAmount }; + const totalUnmigratableObjects = basicObjects + nativeTokens + nftObjects; + + return { totalUnmigratableObjects }; } -function extractStorageDepositReturnAmount( +export function extractStorageDepositReturnAmount( { storage_deposit_return_uc }: CommonOutputObjectWithUc, address: string, -): number | null { +): bigint | null { if ( storage_deposit_return_uc?.fields && storage_deposit_return_uc?.fields.return_address === address ) { - return parseInt(storage_deposit_return_uc?.fields.return_amount); + return BigInt(storage_deposit_return_uc?.fields.return_amount); } return null; } -function extractOutputFields(outputObject: IotaObjectData): CommonOutputObjectWithUc { +export function extractMigrationOutputFields( + outputObject: IotaObjectData, +): CommonOutputObjectWithUc { return ( outputObject.content as unknown as { fields: CommonOutputObjectWithUc; diff --git a/apps/wallet-dashboard/lib/utils/migration/index.ts b/apps/wallet-dashboard/lib/utils/migration/index.ts new file mode 100644 index 00000000000..8dbde02f32c --- /dev/null +++ b/apps/wallet-dashboard/lib/utils/migration/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from './filterMigrationObjectDetails'; +export * from './groupMigrationObjects'; +export * from './groupStardustObjectsByMigrationStatus';