diff --git a/apps/core/src/constants/timelock.constants.ts b/apps/core/src/constants/timelock.constants.ts index 21c7999aef0..f3fe99685d0 100644 --- a/apps/core/src/constants/timelock.constants.ts +++ b/apps/core/src/constants/timelock.constants.ts @@ -2,5 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 export const TIMELOCK_MODULE = 'timelock'; +export const TIMELOCKED_STAKING_MODULE = 'timelocked_staking'; export const TIMELOCK_IOTA_TYPE = `0x2::${TIMELOCK_MODULE}::TimeLock<0x2::balance::Balance<0x2::iota::IOTA>>`; -export const TIMELOCK_STAKED_TYPE = `0x3::timelocked_staking::TimelockedStakedIota`; +export const TIMELOCK_STAKED_TYPE = `0x3::${TIMELOCKED_STAKING_MODULE}::TimelockedStakedIota`; diff --git a/apps/core/src/hooks/index.ts b/apps/core/src/hooks/index.ts index df8267bb812..3a2680bbe3a 100644 --- a/apps/core/src/hooks/index.ts +++ b/apps/core/src/hooks/index.ts @@ -32,7 +32,6 @@ export * from './useQueryTransactionsByAddress'; export * from './useGetTransaction'; export * from './useSortedCoinsByCategories'; export * from './useGetNFTDisplay'; -export * from './useUnlockTimelockedObjectsTransaction'; export * from './useGetAllOwnedObjects'; export * from './useGetTimelockedStakedObjects'; export * from './useGetStakingValidatorDetails'; diff --git a/apps/core/src/hooks/useUnlockTimelockedObjectsTransaction.ts b/apps/core/src/hooks/useUnlockTimelockedObjectsTransaction.ts deleted file mode 100644 index f568844d746..00000000000 --- a/apps/core/src/hooks/useUnlockTimelockedObjectsTransaction.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2024 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import { useIotaClient } from '@iota/dapp-kit'; -import { createUnlockTimelockedObjectsTransaction } from '../utils'; -import { useQuery } from '@tanstack/react-query'; -import { useMaxTransactionSizeBytes } from './useMaxTransactionSizeBytes'; - -export function useUnlockTimelockedObjectsTransaction(address: string, objectIds: string[]) { - const client = useIotaClient(); - const { data: maxSizeBytes = Infinity } = useMaxTransactionSizeBytes(); - - return useQuery({ - // eslint-disable-next-line @tanstack/query/exhaustive-deps - queryKey: ['unlock-timelocked-objects', address, objectIds], - queryFn: async () => { - const transaction = createUnlockTimelockedObjectsTransaction({ address, objectIds }); - transaction.setSender(address); - await transaction.build({ client, maxSizeBytes }); - return transaction; - }, - enabled: !!address && !!objectIds?.length, - gcTime: 0, - select: (transaction) => { - return { - transactionBlock: transaction, - }; - }, - }); -} diff --git a/apps/core/src/types/index.ts b/apps/core/src/types/index.ts index 158b823ad5f..15ea48fd759 100644 --- a/apps/core/src/types/index.ts +++ b/apps/core/src/types/index.ts @@ -9,3 +9,5 @@ export * from './gasSummary'; export * from './transactionExecute'; export * from './validators'; export * from './schema'; +export * from './nestedResult'; +export * from './stakeObject'; diff --git a/apps/core/src/types/nestedResult.ts b/apps/core/src/types/nestedResult.ts new file mode 100644 index 00000000000..d137f9ecacf --- /dev/null +++ b/apps/core/src/types/nestedResult.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export type NestedResultType = { + $kind: 'NestedResult'; + NestedResult: [number, number]; +}; diff --git a/apps/core/src/types/stakeObject.ts b/apps/core/src/types/stakeObject.ts new file mode 100644 index 00000000000..9ad54fd8aad --- /dev/null +++ b/apps/core/src/types/stakeObject.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// Timelocked stake: fields.staked_iota.fields.{pool_id, stake_activation_epoch} +export interface TimelockedStakeObjectInput { + objectId: string; + content: { + dataType: 'moveObject'; + fields: { + staked_iota: { + fields: { + pool_id: string; + stake_activation_epoch: string; + }; + }; + }; + }; +} + +// Regular stake: fields.{pool_id, stake_activation_epoch} +export interface RegularStakeObjectInput { + objectId: string; + content: { + dataType: 'moveObject'; + fields: { + pool_id: string; + stake_activation_epoch: string; + }; + }; +} diff --git a/apps/core/src/utils/migration/createMigrationTransaction.ts b/apps/core/src/utils/migration/createMigrationTransaction.ts index 804531fb01b..d632e2d2352 100644 --- a/apps/core/src/utils/migration/createMigrationTransaction.ts +++ b/apps/core/src/utils/migration/createMigrationTransaction.ts @@ -11,11 +11,7 @@ import { NftOutputObject, NftOutputObjectSchema, } from './types'; - -type NestedResultType = { - $kind: 'NestedResult'; - NestedResult: [number, number]; -}; +import { NestedResultType } from '../../types'; export async function getNativeTokensFromBag(bagId: string, client: IotaClient) { const nativeTokenDynamicFields = await client.getDynamicFields({ diff --git a/apps/core/src/utils/transaction/createCollectAllTimelocksTransaction.ts b/apps/core/src/utils/transaction/createCollectAllTimelocksTransaction.ts new file mode 100644 index 00000000000..a226a92b231 --- /dev/null +++ b/apps/core/src/utils/transaction/createCollectAllTimelocksTransaction.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { Transaction } from '@iota/iota-sdk/transactions'; +import { + IOTA_TYPE_ARG, + IOTA_FRAMEWORK_ADDRESS, + IOTA_CLOCK_OBJECT_ID, + IOTA_SYSTEM_ADDRESS, +} from '@iota/iota-sdk/utils'; +import { NestedResultType, RegularStakeObjectInput, TimelockedStakeObjectInput } from '../../types'; + +interface CreateCollectAllTimelocksTransactionOptions { + address: string; + timelockObjectIds: string[]; + timelockedStakedObjects?: TimelockedStakeObjectInput[]; + existingStakedObjects?: RegularStakeObjectInput[]; +} + +export function createCollectAllTimelocksTransaction({ + address, + timelockObjectIds, + timelockedStakedObjects = [], + existingStakedObjects = [], +}: CreateCollectAllTimelocksTransactionOptions) { + const ptb = new Transaction(); + const coins: NestedResultType[] = []; + + // Unlock regular timelocks and convert to coins + for (const objectId of timelockObjectIds) { + const [unlock] = ptb.moveCall({ + target: `${IOTA_FRAMEWORK_ADDRESS}::timelock::unlock_with_clock`, + typeArguments: [`${IOTA_FRAMEWORK_ADDRESS}::balance::Balance<${IOTA_TYPE_ARG}>`], + arguments: [ptb.object(objectId), ptb.object(IOTA_CLOCK_OBJECT_ID)], + }); + + const [coin] = ptb.moveCall({ + target: `${IOTA_FRAMEWORK_ADDRESS}::coin::from_balance`, + typeArguments: [IOTA_TYPE_ARG], + arguments: [ptb.object(unlock)], + }); + + coins.push(coin); + } + + // Unlock timelock stakes and group by (pool_id, stake_activation_epoch) + const stakedIotaByKey = new Map(); + + for (const stakedObject of timelockedStakedObjects) { + const [unlockedStakedIota] = ptb.moveCall({ + target: `${IOTA_SYSTEM_ADDRESS}::timelocked_staking::unlock_with_clock`, + arguments: [ptb.object(stakedObject.objectId), ptb.object(IOTA_CLOCK_OBJECT_ID)], + }); + + const poolKey = extractPoolKey(stakedObject); + if (poolKey) { + if (!stakedIotaByKey.has(poolKey)) { + stakedIotaByKey.set(poolKey, []); + } + stakedIotaByKey.get(poolKey)!.push(unlockedStakedIota); + } else { + ptb.transferObjects([unlockedStakedIota], ptb.pure.address(address)); + } + } + + for (const [poolKey, stakedIotaObjects] of stakedIotaByKey.entries()) { + const existingStake = findExistingStakeForKey(existingStakedObjects, poolKey); + + if (existingStake) { + // Join all unlocked stakes into the existing regular stake + for (const stake of stakedIotaObjects) { + ptb.moveCall({ + target: `${IOTA_SYSTEM_ADDRESS}::staking_pool::join_staked_iota`, + arguments: [ptb.object(existingStake.objectId), stake], + }); + } + } else if (stakedIotaObjects.length === 1) { + ptb.transferObjects([stakedIotaObjects[0]], ptb.pure.address(address)); + } else { + // Join all into the first one, then transfer + const [first, ...rest] = stakedIotaObjects; + for (const stake of rest) { + ptb.moveCall({ + target: `${IOTA_SYSTEM_ADDRESS}::staking_pool::join_staked_iota`, + arguments: [first, stake], + }); + } + ptb.transferObjects([first], ptb.pure.address(address)); + } + } + + // Transfer all collected coins + if (coins.length > 0) { + ptb.transferObjects(coins, ptb.pure.address(address)); + } + + return ptb; +} + +function extractPoolKey(stakedObject: TimelockedStakeObjectInput): string | null { + const stakedIotaFields = stakedObject.content.fields.staked_iota?.fields; + if (stakedIotaFields?.pool_id && stakedIotaFields?.stake_activation_epoch) { + return `${stakedIotaFields.pool_id}:${stakedIotaFields.stake_activation_epoch}`; + } + return null; +} + +function findExistingStakeForKey( + existingStakes: RegularStakeObjectInput[], + poolKey: string, +): RegularStakeObjectInput | undefined { + return existingStakes.find( + (s) => `${s.content.fields.pool_id}:${s.content.fields.stake_activation_epoch}` === poolKey, + ); +} diff --git a/apps/core/src/utils/transaction/createUnlockTimelockedObjectsTransaction.ts b/apps/core/src/utils/transaction/createUnlockTimelockedObjectsTransaction.ts index d8e0abf9898..6f3ec39105e 100644 --- a/apps/core/src/utils/transaction/createUnlockTimelockedObjectsTransaction.ts +++ b/apps/core/src/utils/transaction/createUnlockTimelockedObjectsTransaction.ts @@ -1,8 +1,7 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { Transaction } from '@iota/iota-sdk/transactions'; -import { IOTA_TYPE_ARG, IOTA_FRAMEWORK_ADDRESS, IOTA_CLOCK_OBJECT_ID } from '@iota/iota-sdk/utils'; +import { createCollectAllTimelocksTransaction } from './createCollectAllTimelocksTransaction'; interface CreateUnlockTimelockedObjectTransactionOptions { address: string; @@ -13,25 +12,8 @@ export function createUnlockTimelockedObjectsTransaction({ address, objectIds, }: CreateUnlockTimelockedObjectTransactionOptions) { - const ptb = new Transaction(); - const coins: { $kind: 'NestedResult'; NestedResult: [number, number] }[] = []; - - for (const objectId of objectIds) { - const [unlock] = ptb.moveCall({ - target: `${IOTA_FRAMEWORK_ADDRESS}::timelock::unlock_with_clock`, - typeArguments: [`${IOTA_FRAMEWORK_ADDRESS}::balance::Balance<${IOTA_TYPE_ARG}>`], - arguments: [ptb.object(objectId), ptb.object(IOTA_CLOCK_OBJECT_ID)], - }); - - // Convert Balance to Coin - const [coin] = ptb.moveCall({ - target: `${IOTA_FRAMEWORK_ADDRESS}::coin::from_balance`, - typeArguments: [IOTA_TYPE_ARG], - arguments: [ptb.object(unlock)], - }); - - coins.push(coin); - } - ptb.transferObjects(coins, ptb.pure.address(address)); - return ptb; + return createCollectAllTimelocksTransaction({ + address, + timelockObjectIds: objectIds, + }); } diff --git a/apps/core/src/utils/transaction/index.ts b/apps/core/src/utils/transaction/index.ts index 375e87ca890..97d70a722b7 100644 --- a/apps/core/src/utils/transaction/index.ts +++ b/apps/core/src/utils/transaction/index.ts @@ -13,5 +13,6 @@ export * from './createTokenTransferTransaction'; export * from './getObjectDisplayLookup'; export * from './createNftSendValidationSchema'; export * from './createUnlockTimelockedObjectsTransaction'; +export * from './createCollectAllTimelocksTransaction'; export * from './isMigrationTransaction'; export * from './isUnlockTimelockedObjectTransaction'; diff --git a/apps/wallet-dashboard/app/(protected)/(feature-controlled)/vesting/page.tsx b/apps/wallet-dashboard/app/(protected)/(feature-controlled)/vesting/page.tsx index c2e7bce6e58..5a7d3232b61 100644 --- a/apps/wallet-dashboard/app/(protected)/(feature-controlled)/vesting/page.tsx +++ b/apps/wallet-dashboard/app/(protected)/(feature-controlled)/vesting/page.tsx @@ -8,12 +8,15 @@ import { useStakeDialog, VestingScheduleDialog, UnstakeDialog, + SupplyIncreaseVestingOverview, StakeDialogView, + CollectTransactionDialog, } from '@/components'; import { UnstakeDialogView } from '@/components/dialogs/unstake/enums'; import { useUnstakeDialog } from '@/components/dialogs/unstake/hooks'; import { useGetSupplyIncreaseVestingObjects } from '@/hooks'; import { groupTimelockedStakedObjects, TimelockedStakedObjectsGrouped } from '@/lib/utils'; +import { SupplyIncreaseUserType } from '@/lib/interfaces'; import { Panel, Title, @@ -63,18 +66,20 @@ import { useEffect, useState } from 'react'; import { StakedTimelockObject } from '@/components'; import { IotaSignAndExecuteTransactionOutput } from '@iota/wallet-standard'; import { ampli } from '@/lib/utils/analytics'; -import clsx from 'clsx'; import BigNumber from 'bignumber.js'; export default function VestingDashboardPage(): JSX.Element { const [timelockedObjectsToUnstake, setTimelockedObjectsToUnstake] = useState(null); + const [collectTxDigest, setCollectTxDigest] = useState(null); + const [showCollectTransaction, setShowCollectTransaction] = useState(false); const account = useCurrentAccount(); const address = account?.address || ''; const iotaClient = useIotaClient(); const { data: system } = useIotaClientQuery('getLatestIotaSystemState'); const [isVestingScheduleDialogOpen, setIsVestingScheduleDialogOpen] = useState(false); - const { mutateAsync: signAndExecuteTransaction } = useSignAndExecuteTransaction(); + const { mutateAsync: signAndExecuteTransaction, isPending: isSendingTransaction } = + useSignAndExecuteTransaction(); const { theme } = useTheme(); const { data: balance } = useBalance(address); @@ -87,23 +92,29 @@ export default function VestingDashboardPage(): JSX.Element { nextPayout, supplyIncreaseVestingPortfolio, supplyIncreaseVestingSchedule, + supplyIncreaseVestingMapped, supplyIncreaseVestingStakedMapped, isTimelockedStakedObjectsLoading, unlockAllSupplyIncreaseVesting, refreshStakeList, isSupplyIncreaseVestingScheduleEmpty, - supplyIncreaseVestingMapped, isMaxTransactionSizeError, supplyIncreaseVestingUnlockedMaxSize, isUnlockPending, resetMaxTransactionSize, isUnlockError, unlockError, + userType, + inactiveValidatorUnlockedStakes, } = useGetSupplyIncreaseVestingObjects(address); const timelockedStakedObjectsGrouped: TimelockedStakedObjectsGrouped[] = groupTimelockedStakedObjects(supplyIncreaseVestingStakedMapped || []); + const inactiveValidatorAddresses = new Set( + inactiveValidatorUnlockedStakes.map((stake) => stake.validatorAddress), + ); + const { isDialogStakeOpen, stakeDialogView, @@ -208,22 +219,20 @@ export default function VestingDashboardPage(): JSX.Element { }, { onSuccess: (tx) => { - handleOnSuccess(tx.digest); + setCollectTxDigest(tx.digest); + setShowCollectTransaction(true); ampli.timelockCollect(); + toast.success('Collect transaction has been sent'); if (isMaxTransactionSizeError) { resetMaxTransactionSize(); } }, }, - ) - .then(() => { - toast.success('Collect transaction has been sent'); - }) - .catch((error) => { - toast.error('Collect transaction was not sent'); - console.error('Error executing collect transaction:', error); - }); + ).catch((error) => { + toast.error('Collect transaction was not sent'); + console.error('Error executing collect transaction:', error); + }); }; function handleUnstake(delegatedTimelockedStake: TimelockedStakedObjectsGrouped): void { @@ -255,18 +264,199 @@ export default function VestingDashboardPage(): JSX.Element { !!supplyIncreaseVestingSchedule.availableClaiming && supplyIncreaseVestingSchedule.availableClaiming !== 0n; + // Simplified UI for Staker users + if (userType === SupplyIncreaseUserType.Staker) { + return ( + <> +
+
+ + ) : undefined + } + disabled={ + !supplyIncreaseVestingSchedule.availableClaiming || + supplyIncreaseVestingSchedule.availableClaiming === 0n || + isUnlockPending || + inactiveValidatorUnlockedStakes.length > 0 || + isSendingTransaction + } + fullWidth + /> + } + /> + + + <Button + type={ButtonType.Secondary} + onClick={openReceiveTokenDialog} + text="Rewards Schedule" + icon={<StarHex />} + disabled={!supplyIncreaseVestingPortfolio} + size={ButtonSize.Small} + /> + </div> + } + /> + <div className="flex flex-col gap-md p-lg pt-sm"> + <div className="flex h-24 flex-row gap-md"> + <DisplayStats + label="Total Vested" + value={formattedTotalVested} + supportingLabel={vestedSymbol} + /> + <DisplayStats + label="Available Rewards" + value={formattedAvailableClaiming} + supportingLabel={availableClaimingSymbol} + tooltipText="Total amount of IOTA that is available to collect." + tooltipPosition={TooltipPosition.Right} + /> + </div> + {isMaxTransactionSizeError ? ( + <InfoBox + title="Partial collect" + supportingText={`Due to the large number of objects, a partial collect will be attempted for ${formattedSupplyIncreaseVestingUnlockedMaxSize} ${supplyIncreaseVestingUnlockedMaxSizeSymbol}. After the operation is complete, you can collect the remaining value.`} + style={InfoBoxStyle.Elevated} + type={InfoBoxType.Warning} + icon={<Warning />} + /> + ) : null} + {supplyIncreaseVestingPortfolio && ( + <VestingScheduleDialog + open={isVestingScheduleDialogOpen} + setOpen={setIsVestingScheduleDialogOpen} + vestingPortfolio={supplyIncreaseVestingPortfolio} + userType={userType} + /> + )} + </div> + </Panel> + </div> + + {!isSupplyIncreaseVestingScheduleEmpty && + supplyIncreaseVestingSchedule.totalStaked !== 0n ? ( + <div className="flex w-full md:w-3/4"> + <Panel> + <Title title="Staked Vesting" /> + + <div className="flex flex-col gap-y-md px-lg py-sm"> + {inactiveValidatorUnlockedStakes.length > 0 && ( + <InfoBox + title="Inactive validator" + supportingText="Some timelocked stakes cannot be collected because their validator is no longer active. Please unstake them first." + style={InfoBoxStyle.Elevated} + type={InfoBoxType.Warning} + icon={<Warning />} + /> + )} + <div className="flex flex-row gap-x-md"> + <DisplayStats + label="Your stake" + value={`${totalStakedFormatted} ${totalStakedSymbol}`} + /> + <DisplayStats + label="Earned" + value={`${totalEarnedFormatted} ${totalEarnedSymbol}`} + /> + </div> + <div className="flex w-full"> + <Card type={CardType.Filled}> + <CardBody + title="" + subtitle={ + <LabelText + size={LabelTextSize.Large} + label="Available for staking" + text={formattedAvailableStaking} + supportingLabel={availableStakingSymbol} + /> + } + /> + </Card> + </div> + </div> + <div className="flex flex-col px-lg py-sm"> + <div className="flex w-full flex-col items-center justify-center space-y-4 pt-4"> + {system && + timelockedStakedObjectsGrouped?.map( + (timelockedStakedObject) => { + return ( + <StakedTimelockObject + key={ + timelockedStakedObject.validatorAddress + + timelockedStakedObject.stakeRequestEpoch + + timelockedStakedObject.label + } + getValidatorByAddress={ + getValidatorByAddress + } + timelockedStakedObject={ + timelockedStakedObject + } + handleUnstake={handleUnstake} + currentEpoch={Number(system.epoch)} + showUnstakeButton={inactiveValidatorAddresses.has( + timelockedStakedObject.validatorAddress, + )} + /> + ); + }, + )} + </div> + </div> + </Panel> + </div> + ) : null} + </div> + <UnstakeDialog + {...defaultDialogProps} + groupedTimelockedObjects={timelockedObjectsToUnstake || undefined} + onSuccess={handleOnSuccessUnstake} + /> + <StakeDialog + isTimelockedStaking={true} + stakedDetails={selectedStake} + onSuccess={handleOnSuccess} + handleClose={handleCloseStakeDialog} + view={stakeDialogView} + setView={setStakeDialogView} + selectedValidator={selectedValidator} + setSelectedValidator={setSelectedValidator} + maxStakableTimelockedAmount={BigInt( + supplyIncreaseVestingSchedule.availableStaking, + )} + /> + {showCollectTransaction && collectTxDigest && ( + <CollectTransactionDialog + open={showCollectTransaction} + txDigest={collectTxDigest} + onClose={() => { + setShowCollectTransaction(false); + refreshStakeList(); + }} + /> + )} + </> + ); + } + + // Full UI for Entity users (investors) - original structure return ( <> - <div className="flex w-full flex-col items-stretch justify-center gap-lg justify-self-center md:flex-row"> - <div - className={clsx( - 'flex w-full flex-col gap-lg', - !isSupplyIncreaseVestingScheduleEmpty && - supplyIncreaseVestingSchedule.totalStaked !== 0n - ? 'md:w-1/2' - : 'md:w-2/3', - )} - > + <div className="flex w-full flex-col items-center justify-center gap-lg justify-self-center"> + <div className="flex w-full flex-col gap-lg md:w-3/4"> <Panel> <Title title="Vesting" size={TitleSize.Medium} /> <div className="flex flex-col gap-md p-lg pt-sm"> @@ -330,11 +520,7 @@ export default function VestingDashboardPage(): JSX.Element { </CardImage> <CardBody title={`${formattedNextPayout} ${nextPayoutSymbol}`} - subtitle={`Next payout ${ - nextPayout?.expirationTimestampMs - ? formattedLastPayoutExpirationTime - : '' - }`} + subtitle={`Next payout ${nextPayout?.expirationTimestampMs ? formattedLastPayoutExpirationTime : ''}`} /> <CardAction type={CardActionType.Button} @@ -349,6 +535,7 @@ export default function VestingDashboardPage(): JSX.Element { open={isVestingScheduleDialogOpen} setOpen={setIsVestingScheduleDialogOpen} vestingPortfolio={supplyIncreaseVestingPortfolio} + userType={userType} /> )} </div> @@ -374,7 +561,7 @@ export default function VestingDashboardPage(): JSX.Element { {!isSupplyIncreaseVestingScheduleEmpty && supplyIncreaseVestingSchedule.totalStaked !== 0n ? ( - <div className="flex w-full md:w-1/2"> + <div className="flex w-full md:w-3/4"> <Panel> <Title title="Staked Vesting" diff --git a/apps/wallet-dashboard/app/(protected)/home/page.tsx b/apps/wallet-dashboard/app/(protected)/home/page.tsx index 22972ac4fcd..2be95d7431f 100644 --- a/apps/wallet-dashboard/app/(protected)/home/page.tsx +++ b/apps/wallet-dashboard/app/(protected)/home/page.tsx @@ -16,11 +16,18 @@ import { useFeature } from '@iota/apps-backend-client'; import { Feature } from '@iota/core'; import { useCurrentAccount, useCurrentWallet } from '@iota/dapp-kit'; import { useEffect, useState } from 'react'; +import { Button, ButtonType } from '@iota/apps-ui-kit'; +import { useRouter } from 'next/navigation'; +import { useGetSupplyIncreaseVestingObjects } from '@/hooks'; +import { SupplyIncreaseUserType } from '@/lib/interfaces'; function HomeDashboardPage(): JSX.Element { const [interstitialDismissed, setInterstitialDismissed] = useState<boolean>(false); const { connectionStatus } = useCurrentWallet(); const account = useCurrentAccount(); + const router = useRouter(); + const address = account?.address || ''; + const { userType } = useGetSupplyIncreaseVestingObjects(address); const stardustMigrationEnabled = useFeature<boolean>(Feature.StardustMigration).value; const supplyIncreaseVestingEnabled = useFeature<boolean>(Feature.SupplyIncreaseVesting).value; @@ -57,7 +64,19 @@ function HomeDashboardPage(): JSX.Element { <div style={{ gridArea: 'coins' }} className="flex grow overflow-hidden"> <MyCoins /> </div> - {supplyIncreaseVestingEnabled && <SupplyIncreaseVestingOverview />} + {supplyIncreaseVestingEnabled && ( + <SupplyIncreaseVestingOverview + customButton={ + userType === SupplyIncreaseUserType.Staker ? ( + <Button + type={ButtonType.Primary} + text="Go to Vesting Page" + onClick={() => router.push('/vesting')} + /> + ) : undefined + } + /> + )} <div style={{ gridArea: 'activity' }} className="flex grow overflow-hidden"> <TransactionsOverview /> </div> diff --git a/apps/wallet-dashboard/components/SupplyIncreaseVestingOverview.tsx b/apps/wallet-dashboard/components/SupplyIncreaseVestingOverview.tsx index 7a89c8c903b..cbea7d46e2c 100644 --- a/apps/wallet-dashboard/components/SupplyIncreaseVestingOverview.tsx +++ b/apps/wallet-dashboard/components/SupplyIncreaseVestingOverview.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +import { ReactNode } from 'react'; import { useCurrentAccount, useIotaClient } from '@iota/dapp-kit'; import { useGetSupplyIncreaseVestingObjects } from '@/hooks'; import { @@ -10,6 +11,9 @@ import { CardActionType, CardBody, CardType, + InfoBox, + InfoBoxStyle, + InfoBoxType, LabelText, LabelTextSize, Panel, @@ -17,10 +21,17 @@ import { } from '@iota/apps-ui-kit'; import { StakeDialog, useStakeDialog } from './dialogs'; import { TIMELOCK_IOTA_TYPE, useCountdownByTimestamp, useFormatCoin } from '@iota/core'; -import { Clock } from '@iota/apps-ui-icons'; +import { Clock, Vesting } from '@iota/apps-ui-icons'; import { useQueryClient } from '@tanstack/react-query'; +import { SupplyIncreaseUserType } from '@/lib/interfaces'; -export function SupplyIncreaseVestingOverview() { +interface SupplyIncreaseVestingOverviewProps { + customButton?: ReactNode; +} + +export function SupplyIncreaseVestingOverview({ + customButton, +}: SupplyIncreaseVestingOverviewProps = {}) { const account = useCurrentAccount(); const address = account?.address || ''; const iotaClient = useIotaClient(); @@ -30,6 +41,7 @@ export function SupplyIncreaseVestingOverview() { supplyIncreaseVestingSchedule, isSupplyIncreaseVestingScheduleEmpty, supplyIncreaseVestingStakedMapped, + userType, } = useGetSupplyIncreaseVestingObjects(address); const { @@ -75,6 +87,32 @@ export function SupplyIncreaseVestingOverview() { }); } + // Show simplified UI for Staker users + if (userType === SupplyIncreaseUserType.Staker) { + return !isSupplyIncreaseVestingScheduleEmpty || + supplyIncreaseVestingStakedMapped.length > 0 ? ( + <div style={{ gridArea: 'vesting' }} className="with-vesting flex grow overflow-hidden"> + <Panel> + <div className="flex w-full flex-col items-center justify-between gap-md p-md sm:flex-row"> + <InfoBox + title="Your vesting period has ended" + supportingText="Claim your rewards and migrate your stake now to make your tokens fully compatible with your favorite wallets and ready for use." + type={InfoBoxType.Warning} + style={InfoBoxStyle.Default} + icon={<Vesting />} + /> + {customButton && ( + <div className="flex shrink-0 flex-col items-center justify-center"> + {customButton} + </div> + )} + </div> + </Panel> + </div> + ) : null; + } + + // Show full UI for Entity users (investors) return !isSupplyIncreaseVestingScheduleEmpty || supplyIncreaseVestingStakedMapped.length > 0 ? ( <div style={{ gridArea: 'vesting' }} className="with-vesting flex grow overflow-hidden"> <Panel> @@ -91,7 +129,7 @@ export function SupplyIncreaseVestingOverview() { text={ nextPayoutResult.isPending ? '-' - : `${formattedNextPayout} ` + : `${formattedNextPayout} ` } supportingLabel={nextPayoutSymbol} /> diff --git a/apps/wallet-dashboard/components/dialogs/vesting/CollectTransactionDialog.tsx b/apps/wallet-dashboard/components/dialogs/vesting/CollectTransactionDialog.tsx new file mode 100644 index 00000000000..68389efa295 --- /dev/null +++ b/apps/wallet-dashboard/components/dialogs/vesting/CollectTransactionDialog.tsx @@ -0,0 +1,23 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { Dialog } from '@iota/apps-ui-kit'; +import { TransactionDialogView } from '../TransactionDialog'; + +interface CollectTransactionDialogProps { + open: boolean; + txDigest: string; + onClose: () => void; +} + +export function CollectTransactionDialog({ + open, + txDigest, + onClose, +}: CollectTransactionDialogProps): React.JSX.Element { + return ( + <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> + <TransactionDialogView txDigest={txDigest} onClose={onClose} /> + </Dialog> + ); +} diff --git a/apps/wallet-dashboard/components/dialogs/vesting/VestingScheduleDialog.tsx b/apps/wallet-dashboard/components/dialogs/vesting/VestingScheduleDialog.tsx index 8495836b0da..402092acac9 100644 --- a/apps/wallet-dashboard/components/dialogs/vesting/VestingScheduleDialog.tsx +++ b/apps/wallet-dashboard/components/dialogs/vesting/VestingScheduleDialog.tsx @@ -1,20 +1,31 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { SupplyIncreaseVestingPortfolio } from '@/lib/interfaces'; -import { Dialog, DialogContent, DialogBody, Header } from '@iota/apps-ui-kit'; +import { SupplyIncreaseVestingPortfolio, SupplyIncreaseUserType } from '@/lib/interfaces'; +import { + Dialog, + DialogContent, + DialogBody, + Header, + InfoBox, + InfoBoxStyle, + InfoBoxType, +} from '@iota/apps-ui-kit'; import { VestingScheduleBox } from './VestingScheduleBox'; +import { Warning } from '@iota/apps-ui-icons'; interface VestingScheduleDialogProps { setOpen: (bool: boolean) => void; open: boolean; vestingPortfolio: SupplyIncreaseVestingPortfolio; + userType?: SupplyIncreaseUserType; } export function VestingScheduleDialog({ open, setOpen, vestingPortfolio, + userType, }: VestingScheduleDialogProps): React.JSX.Element { return ( <Dialog open={open} onOpenChange={setOpen}> @@ -24,15 +35,26 @@ export function VestingScheduleDialog({ > <Header title="Rewards Schedule" onClose={() => setOpen(false)} titleCentered /> <DialogBody> - <div className="h-[440px] overflow-y-auto"> - <div className="grid grid-cols-1 gap-sm sm:grid-cols-2 md:grid-cols-4"> - {vestingPortfolio?.map((vestingObject, index) => ( - <VestingScheduleBox - key={index} - amount={vestingObject.amount} - expirationTimestampMs={vestingObject.expirationTimestampMs} - /> - ))} + <div className="flex flex-col gap-md"> + {userType === SupplyIncreaseUserType.Staker && ( + <InfoBox + title="Please note" + supportingText="Amounts are estimates and may not be fully accurate." + style={InfoBoxStyle.Elevated} + type={InfoBoxType.Warning} + icon={<Warning />} + /> + )} + <div className="h-[440px] overflow-y-auto"> + <div className="grid grid-cols-1 gap-sm sm:grid-cols-2 md:grid-cols-4"> + {vestingPortfolio?.map((vestingObject, index) => ( + <VestingScheduleBox + key={index} + amount={vestingObject.amount} + expirationTimestampMs={vestingObject.expirationTimestampMs} + /> + ))} + </div> </div> </div> </DialogBody> diff --git a/apps/wallet-dashboard/components/dialogs/vesting/index.ts b/apps/wallet-dashboard/components/dialogs/vesting/index.ts index ace972f3f49..61aca3d1895 100644 --- a/apps/wallet-dashboard/components/dialogs/vesting/index.ts +++ b/apps/wallet-dashboard/components/dialogs/vesting/index.ts @@ -3,3 +3,4 @@ export * from './VestingScheduleDialog'; export * from './VestingScheduleBox'; +export * from './CollectTransactionDialog'; diff --git a/apps/wallet-dashboard/components/staked-timelock-object/StakedTimelockObject.tsx b/apps/wallet-dashboard/components/staked-timelock-object/StakedTimelockObject.tsx index 01b401db489..c202f361c21 100644 --- a/apps/wallet-dashboard/components/staked-timelock-object/StakedTimelockObject.tsx +++ b/apps/wallet-dashboard/components/staked-timelock-object/StakedTimelockObject.tsx @@ -2,7 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 'use client'; import { TimelockedStakedObjectsGrouped } from '@/lib/utils'; -import { Card, CardImage, CardBody, CardAction, CardActionType } from '@iota/apps-ui-kit'; +import { + ButtonType, + Card, + CardImage, + CardBody, + CardAction, + CardActionType, +} from '@iota/apps-ui-kit'; import { useFormatCoin, ImageIcon, ImageIconSize, useStakeRewardStatus } from '@iota/core'; import { IotaValidatorSummary } from '@iota/iota-sdk/client'; @@ -11,6 +18,7 @@ export interface StakedTimelockObjectProps { handleUnstake: (timelockedStakedObject: TimelockedStakedObjectsGrouped) => void; getValidatorByAddress: (validatorAddress: string) => IotaValidatorSummary | undefined; currentEpoch: number; + showUnstakeButton?: boolean; } export function StakedTimelockObject({ @@ -18,6 +26,7 @@ export function StakedTimelockObject({ timelockedStakedObject, handleUnstake, currentEpoch, + showUnstakeButton = false, }: StakedTimelockObjectProps) { const validatorMeta = getValidatorByAddress(timelockedStakedObject.validatorAddress); @@ -68,11 +77,20 @@ export function StakedTimelockObject({ subtitle={`${sumPrincipalFormatted} ${sumPrincipalSymbol}`} isTextTruncated /> - <CardAction - type={CardActionType.SupportingText} - title={supportingText.title} - subtitle={supportingText.subtitle} - /> + {showUnstakeButton ? ( + <CardAction + type={CardActionType.Button} + title="Unstake" + buttonType={ButtonType.Primary} + onClick={() => handleUnstake(timelockedStakedObject)} + /> + ) : ( + <CardAction + type={CardActionType.SupportingText} + title={supportingText.title} + subtitle={supportingText.subtitle} + /> + )} </Card> ); } diff --git a/apps/wallet-dashboard/hooks/useGetSupplyIncreaseVestingObjects.ts b/apps/wallet-dashboard/hooks/useGetSupplyIncreaseVestingObjects.ts index 8dda75614b7..62f4e86da0b 100644 --- a/apps/wallet-dashboard/hooks/useGetSupplyIncreaseVestingObjects.ts +++ b/apps/wallet-dashboard/hooks/useGetSupplyIncreaseVestingObjects.ts @@ -5,6 +5,7 @@ import { SupplyIncreaseVestingPayout, SupplyIncreaseVestingPortfolio, VestingOverview, + SupplyIncreaseUserType, } from '@/lib/interfaces'; import { buildSupplyIncreaseVestingSchedule, @@ -13,6 +14,7 @@ import { isSizeExceededError, isSupplyIncreaseVestingObject, isTimelockedUnlockable, + getSupplyIncreaseVestingUserType, } from '@/lib/utils'; import { TIMELOCK_IOTA_TYPE, @@ -20,13 +22,16 @@ import { useGetClockTimestamp, useGetTimelockedStakedObjects, TimelockedObject, - useUnlockTimelockedObjectsTransaction, mapTimelockObjects, ExtendedDelegatedTimelockedStake, formatDelegatedTimelockedStake, + createCollectAllTimelocksTransaction, + useGetDelegatedStake, + useIsActiveValidator, } from '@iota/core'; import { Transaction } from '@iota/iota-sdk/transactions'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; +import { useIotaClient } from '@iota/dapp-kit'; const REDUCTION_STEP_SIZE = 5; @@ -51,6 +56,8 @@ interface SupplyIncreaseVestingObject { resetMaxTransactionSize: () => void; isUnlockError: boolean; unlockError: Error | null; + userType: SupplyIncreaseUserType | undefined; + inactiveValidatorUnlockedStakes: ExtendedDelegatedTimelockedStake[]; } export function useGetSupplyIncreaseVestingObjects(address: string): SupplyIncreaseVestingObject { @@ -96,7 +103,15 @@ export function useGetSupplyIncreaseVestingObjects(address: string): SupplyIncre ); const supplyIncreaseVestingPortfolio = - lastPayout && buildSupplyIncreaseVestingSchedule(lastPayout, clockTimestampMs); + lastPayout && buildSupplyIncreaseVestingSchedule(lastPayout); + + const userType = lastPayout ? getSupplyIncreaseVestingUserType([lastPayout]) : undefined; + + // Fetch normal stakes ONLY for Staker users (for join optimization) + const { data: delegatedStakes } = useGetDelegatedStake({ + address: address || '', + enabled: !!address && userType === SupplyIncreaseUserType.Staker, + }); const supplyIncreaseVestingUnlocked = (() => { let filtered = supplyIncreaseVestingMapped?.filter((supplyIncreaseVestingObject) => @@ -110,23 +125,172 @@ export function useGetSupplyIncreaseVestingObjects(address: string): SupplyIncre return filtered; })(); - const supplyIncreaseVestingUnlockedObjectIds: string[] = - supplyIncreaseVestingUnlocked.map((unlockedObject) => unlockedObject.id.id) || []; + const supplyIncreaseVestingUnlockedObjectIds = supplyIncreaseVestingUnlocked.map( + (unlockedObject) => unlockedObject.id.id, + ); const supplyIncreaseVestingUnlockedMaxSize = supplyIncreaseVestingUnlocked.reduce( (acc, curr) => (acc += curr.locked.value), 0n, ); - const { - data: unlockAllSupplyIncreaseVesting, - isPending: isUnlockPending, - isError: isUnlockError, - error: unlockError, - } = useUnlockTimelockedObjectsTransaction( - address || '', + const iotaClient = useIotaClient(); + + const { isActiveValidator } = useIsActiveValidator(); + + // Get unlocked timelocked staked objects (only for Staker users) + const availableTimelockedStakes = useMemo(() => { + if (!timelockedStakedObjects || !clockTimestampMs) return []; + + // Only Stakers can collect timelock stakes - Investors must unstake first + if (userType !== SupplyIncreaseUserType.Staker) return []; + + return formatDelegatedTimelockedStake(timelockedStakedObjects) + .filter(isSupplyIncreaseVestingObject) + .filter((stake) => isTimelockedUnlockable(stake, clockTimestampMs)); + }, [timelockedStakedObjects, clockTimestampMs, userType]); + + // Split by validator status + const { supplyIncreaseVestingUnlockedStakes, inactiveValidatorUnlockedStakes } = useMemo(() => { + const timelockedDelegatedStakes: ExtendedDelegatedTimelockedStake[] = []; + const timelockedStakesDelegatedToInactiveValidator: ExtendedDelegatedTimelockedStake[] = []; + + for (const stake of availableTimelockedStakes) { + if (isActiveValidator(stake.validatorAddress)) { + timelockedDelegatedStakes.push(stake); + } else { + timelockedStakesDelegatedToInactiveValidator.push(stake); + } + } + + return { + supplyIncreaseVestingUnlockedStakes: timelockedDelegatedStakes, + inactiveValidatorUnlockedStakes: timelockedStakesDelegatedToInactiveValidator, + }; + }, [availableTimelockedStakes, isActiveValidator]); + + // Get all timelocked staked object IDs from delegations + const supplyIncreaseVestingUnlockedStakeObjectData = useMemo(() => { + return supplyIncreaseVestingUnlockedStakes.map((stake) => ({ + objectId: stake.timelockedStakedIotaId, + content: { + dataType: 'moveObject' as const, + fields: { + staked_iota: { + fields: { + pool_id: stake.stakingPool, + stake_activation_epoch: stake.stakeActiveEpoch, + }, + }, + }, + }, + })); + }, [supplyIncreaseVestingUnlockedStakes]); + + const existingStakedObjects = useMemo(() => { + if (!delegatedStakes) return []; + + return delegatedStakes.flatMap((delegation) => + delegation.stakes + .filter((stake) => stake.status === 'Active') + .map((stake) => ({ + objectId: stake.stakedIotaId, + content: { + dataType: 'moveObject' as const, + fields: { + pool_id: delegation.stakingPool, + stake_activation_epoch: stake.stakeActiveEpoch, + }, + }, + })), + ); + }, [delegatedStakes]); + + // Build the collect all transaction + const unlockAllSupplyIncreaseVesting = useMemo(() => { + if ( + !address || + (supplyIncreaseVestingUnlockedObjectIds.length === 0 && + supplyIncreaseVestingUnlockedStakeObjectData.length === 0) + ) { + return undefined; + } + + try { + const ptb = createCollectAllTimelocksTransaction({ + address, + timelockObjectIds: supplyIncreaseVestingUnlockedObjectIds, + timelockedStakedObjects: supplyIncreaseVestingUnlockedStakeObjectData, + existingStakedObjects: existingStakedObjects, + }); + + ptb.setSenderIfNotSet(address); + return { transactionBlock: ptb }; + } catch (error) { + return undefined; + } + }, [ + address, supplyIncreaseVestingUnlockedObjectIds, - ); + supplyIncreaseVestingUnlockedStakeObjectData, + existingStakedObjects, + ]); + + // Dry run the transaction to check for errors + const dryRunKey = useMemo(() => { + const objectIds = supplyIncreaseVestingUnlockedObjectIds.join(','); + const stakeIds = supplyIncreaseVestingUnlockedStakeObjectData + .map((s) => s.objectId) + .join(','); + const existingIds = existingStakedObjects.map((s) => s.objectId).join(','); + return `${objectIds}|${stakeIds}|${existingIds}`; + }, [ + supplyIncreaseVestingUnlockedObjectIds, + supplyIncreaseVestingUnlockedStakeObjectData, + existingStakedObjects, + ]); + + const [isUnlockError, setIsUnlockError] = useState(false); + const [unlockError, setUnlockError] = useState<Error | null>(null); + const [isUnlockPending, setIsUnlockPending] = useState(false); + + useEffect(() => { + let isTransactionAborted = false; + + async function dryRunTransaction() { + if (!unlockAllSupplyIncreaseVesting?.transactionBlock) { + setIsUnlockError(false); + setUnlockError(null); + setIsUnlockPending(false); + return; + } + + setIsUnlockPending(true); + try { + const txBytes = await unlockAllSupplyIncreaseVesting.transactionBlock.build({ + client: iotaClient, + }); + await iotaClient.dryRunTransactionBlock({ transactionBlock: txBytes }); + if (!isTransactionAborted) { + setIsUnlockError(false); + setUnlockError(null); + } + } catch (error) { + if (!isTransactionAborted) { + setIsUnlockError(true); + setUnlockError(error as Error); + } + } finally { + if (!isTransactionAborted) setIsUnlockPending(false); + } + } + + dryRunTransaction(); + return () => { + isTransactionAborted = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dryRunKey, iotaClient]); const isSupplyIncreaseVestingScheduleEmpty = !supplyIncreaseVestingSchedule.totalVested && @@ -170,5 +334,7 @@ export function useGetSupplyIncreaseVestingObjects(address: string): SupplyIncre resetMaxTransactionSize, isUnlockError, unlockError, + userType, + inactiveValidatorUnlockedStakes, }; } diff --git a/apps/wallet-dashboard/lib/utils/vesting/vesting.spec.ts b/apps/wallet-dashboard/lib/utils/vesting/vesting.spec.ts index 718db2f28a1..424f07b8064 100644 --- a/apps/wallet-dashboard/lib/utils/vesting/vesting.spec.ts +++ b/apps/wallet-dashboard/lib/utils/vesting/vesting.spec.ts @@ -76,7 +76,7 @@ describe('build supply increase staker vesting portfolio', () => { expect(lastPayout).toBeDefined(); - const vestingPortfolio = buildVestingPortfolio(lastPayout!, Date.now()); + const vestingPortfolio = buildVestingPortfolio(lastPayout!); expect(vestingPortfolio.length).toEqual( getSupplyIncreaseVestingPayoutsCount(SupplyIncreaseUserType.Entity), @@ -94,7 +94,7 @@ describe('build supply increase staker vesting portfolio', () => { expect(lastPayout).toBeDefined(); - const vestingPortfolio = buildVestingPortfolio(lastPayout!, Date.now()); + const vestingPortfolio = buildVestingPortfolio(lastPayout!); expect(vestingPortfolio.length).toEqual( getSupplyIncreaseVestingPayoutsCount(SupplyIncreaseUserType.Entity), @@ -114,7 +114,7 @@ describe('build supply increase staker vesting portfolio', () => { ); expect(lastPayout).toBeDefined(); - const vestingPortfolio = buildVestingPortfolio(lastPayout!, Date.now()); + const vestingPortfolio = buildVestingPortfolio(lastPayout!); expect(vestingPortfolio.length).toEqual( getSupplyIncreaseVestingPayoutsCount(SupplyIncreaseUserType.Entity), ); @@ -135,13 +135,10 @@ describe('vesting overview', () => { const vestingOverview = getVestingOverview(timelockedObjects, Date.now()); expect(vestingOverview.totalVested).toEqual(totalAmount); - const vestingPortfolio = buildVestingPortfolio( - { - amount: lastPayout.locked.value, - expirationTimestampMs: lastPayout.expirationTimestampMs, - }, - Date.now(), - ); + const vestingPortfolio = buildVestingPortfolio({ + amount: lastPayout.locked.value, + expirationTimestampMs: lastPayout.expirationTimestampMs, + }); const lockedAmount = vestingPortfolio.reduce( (acc, current) => @@ -187,13 +184,10 @@ describe('vesting overview', () => { const vestingOverview = getVestingOverview(extendedTimelockedStakedObjects, Date.now()); expect(vestingOverview.totalVested).toEqual(totalAmount); - const vestingPortfolio = buildVestingPortfolio( - { - amount: BigInt(lastPayoutValue), - expirationTimestampMs: Number(lastPayout.expirationTimestampMs), - }, - Date.now(), - ); + const vestingPortfolio = buildVestingPortfolio({ + amount: BigInt(lastPayoutValue), + expirationTimestampMs: Number(lastPayout.expirationTimestampMs), + }); const lockedAmount = vestingPortfolio.reduce( (acc, current) => @@ -215,8 +209,15 @@ describe('vesting overview', () => { expect(vestingOverview.totalStaked).toEqual(totalStaked); - // In this scenario there are no objects to stake or claim because they are all staked - expect(vestingOverview.availableClaiming).toEqual(0n); + // Staked objects with expired timelocks are available for collecting (claiming) + const unlockedStakedAmount = extendedTimelockedStakedObjects.reduce( + (acc, current) => + Number(current.expirationTimestampMs) <= Date.now() + ? acc + BigInt(current.principal) + : acc, + 0n, + ); + expect(vestingOverview.availableClaiming).toEqual(unlockedStakedAmount); expect(vestingOverview.availableStaking).toEqual(0n); }); @@ -241,13 +242,10 @@ describe('vesting overview', () => { const vestingOverview = getVestingOverview(mixedObjects, Date.now()); expect(vestingOverview.totalVested).toEqual(totalAmount); - const vestingPortfolio = buildVestingPortfolio( - { - amount: lastPayout.amount, - expirationTimestampMs: lastPayout.expirationTimestampMs, - }, - Date.now(), - ); + const vestingPortfolio = buildVestingPortfolio({ + amount: lastPayout.amount, + expirationTimestampMs: lastPayout.expirationTimestampMs, + }); const lockedAmount = vestingPortfolio.reduce( (acc, current) => @@ -266,11 +264,19 @@ describe('vesting overview', () => { expect(vestingOverview.totalStaked).toEqual(totalStaked); const timelockObjects = mixedObjects.filter(isTimelockedObject); - const availableClaiming = timelockObjects.reduce( + const unlockedStakedAmount = extendedTimelockedStakedObjects.reduce( (acc, current) => - current.expirationTimestampMs <= Date.now() ? acc + current.locked.value : acc, + Number(current.expirationTimestampMs) <= Date.now() + ? acc + BigInt(current.principal) + : acc, 0n, ); + const availableClaiming = + timelockObjects.reduce( + (acc, current) => + current.expirationTimestampMs <= Date.now() ? acc + current.locked.value : acc, + 0n, + ) + unlockedStakedAmount; const availableStaking = timelockObjects.reduce( (acc, current) => current.expirationTimestampMs > Date.now() ? acc + current.locked.value : acc, diff --git a/apps/wallet-dashboard/lib/utils/vesting/vesting.ts b/apps/wallet-dashboard/lib/utils/vesting/vesting.ts index 8f46d7b1ccf..87aa2e64492 100644 --- a/apps/wallet-dashboard/lib/utils/vesting/vesting.ts +++ b/apps/wallet-dashboard/lib/utils/vesting/vesting.ts @@ -115,12 +115,10 @@ export function getSupplyIncreaseVestingUserType( export function buildSupplyIncreaseVestingSchedule( referencePayout: SupplyIncreaseVestingPayout, - timestampMs: number, ): SupplyIncreaseVestingPortfolio { const userType = getSupplyIncreaseVestingUserType([referencePayout]); - if (!userType || timestampMs >= referencePayout.expirationTimestampMs) { - // if the latest payout has already been unlocked, we cant build a vesting schedule + if (!userType) { return []; } @@ -162,7 +160,7 @@ export function getVestingOverview( const vestingPayoutsCount = getSupplyIncreaseVestingPayoutsCount(userType!); // Note: we add the initial payout to the total rewards, 10% of the total rewards are paid out immediately const totalVestedAmount = (BigInt(vestingPayoutsCount) * latestPayout.amount * 10n) / 9n; - const vestingPortfolio = buildSupplyIncreaseVestingSchedule(latestPayout, timestampMs); + const vestingPortfolio = buildSupplyIncreaseVestingSchedule(latestPayout); const totalLockedAmount = vestingPortfolio.reduce( (acc, current) => current.expirationTimestampMs > timestampMs ? acc + BigInt(current.amount) : acc, @@ -184,11 +182,26 @@ export function getVestingOverview( const timelockedObjects = vestingObjects.filter(isTimelockedObject); - const totalAvailableClaimingAmount = timelockedObjects.reduce( + let totalAvailableClaimingAmount = timelockedObjects.reduce( (acc, current) => current.expirationTimestampMs <= timestampMs ? acc + BigInt(current.locked.value) : acc, 0n, ); + + // Count unlocked timelock stakes (only for Staker users) + if (userType === SupplyIncreaseUserType.Staker) { + const unlockedTimelockedStakes = timelockedStakedObjects.filter( + (stake) => Number(stake.expirationTimestampMs) <= timestampMs, + ); + const totalUnlockedStakesAmount = unlockedTimelockedStakes.reduce( + (acc, current) => acc + BigInt(current.principal), + 0n, + ); + + // Add unlocked stakes to available claiming + totalAvailableClaimingAmount += totalUnlockedStakesAmount; + } + const totalAvailableStakingAmount = timelockedObjects.reduce( (acc, current) => current.expirationTimestampMs > timestampMs && diff --git a/apps/wallet/src/ui/app/pages/home/tokens/OverviewHint.tsx b/apps/wallet/src/ui/app/pages/home/tokens/OverviewHint.tsx index 779a0a3a372..c6d05d29b46 100644 --- a/apps/wallet/src/ui/app/pages/home/tokens/OverviewHint.tsx +++ b/apps/wallet/src/ui/app/pages/home/tokens/OverviewHint.tsx @@ -7,9 +7,10 @@ interface OverviewHintProps { onClick: () => void; icon: ElementType; title: string; + subtitle?: string; } -export function OverviewHint({ onClick, icon, title }: OverviewHintProps) { +export function OverviewHint({ onClick, icon, title, subtitle }: OverviewHintProps) { const IconComponent = icon; return ( <div @@ -17,9 +18,14 @@ export function OverviewHint({ onClick, icon, title }: OverviewHintProps) { onClick={onClick} > <IconComponent className="h-5 w-5 text-iota-warning-10 dark:text-iota-warning-90" /> - <span className="text-label-sm text-iota-neutral-10 dark:text-iota-neutral-92"> - {title} - </span> + <div className="flex flex-col text-label-sm"> + <span className="text-iota-neutral-10 dark:text-iota-neutral-92">{title}</span> + {subtitle && ( + <span className="text-iota-neutral-40 dark:text-iota-neutral-60"> + {subtitle} + </span> + )} + </div> </div> ); } diff --git a/apps/wallet/src/ui/app/pages/home/tokens/SupplyIncreaseVestingStakingDialog.tsx b/apps/wallet/src/ui/app/pages/home/tokens/SupplyIncreaseVestingStakingDialog.tsx index 216389a9aff..38491f837fa 100644 --- a/apps/wallet/src/ui/app/pages/home/tokens/SupplyIncreaseVestingStakingDialog.tsx +++ b/apps/wallet/src/ui/app/pages/home/tokens/SupplyIncreaseVestingStakingDialog.tsx @@ -2,10 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { ArrowTopRight } from '@iota/apps-ui-icons'; -import { Button, Dialog, DialogContent, DialogBody, Header, Panel } from '@iota/apps-ui-kit'; +import { Button, Dialog, DialogContent, DialogBody, Header } from '@iota/apps-ui-kit'; import { Banner, BannerSize, Theme, useTheme } from '@iota/core'; import { WALLET_DASHBOARD_URL } from '_src/shared/constants'; -import { Link } from 'react-router-dom'; import { ampli } from '_src/shared/analytics/ampli'; interface SupplyIncreaseVestingStakingDialogProps { @@ -30,50 +29,28 @@ export function SupplyIncreaseVestingStakingDialog({ }); window.open(WALLET_DASHBOARD_URL, '_blank', 'noopener noreferrer'); } + return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent containerId="overlay-portal-container"> - <Header title="Vesting" onClose={() => setOpen(false)} titleCentered /> + <Header title="Action Required" onClose={() => setOpen(false)} titleCentered /> <DialogBody> <div className="flex flex-col gap-sm text-center"> <Banner videoSrc={videoSrc} - title="Vesting" - subtitle="Get an overview of your vested tokens and manage them" + title="Vesting has ended" + subtitle="Claim your rewards and migrate your stake now to make your tokens fully compatible with your favorite wallets and ready for use." size={BannerSize.Small} - > - <div className="flex w-full flex-wrap justify-start gap-xs text-body-sm text-iota-primary-30 dark:text-iota-primary-80"> - <Link - to="https://docs.iota.org/users/iota-wallet-dashboard/how-to/vesting" - target="_blank" - rel="noreferrer" - className="flex items-center gap-x-xxs underline" - > - <span className="shrink-0">Docs</span> - <ArrowTopRight /> - </Link> - </div> - </Banner> - <Panel bgColor="bg-iota-secondary-90 dark:bg-iota-secondary-10"> - <div className="flex flex-col items-start justify-start gap-xs p-md text-start"> - <span className="text-title-sm text-iota-neutral-10 dark:text-iota-neutral-92"> - Step-by-step - </span> - <ol className="list-decimal space-y-xs pl-md text-body-sm text-iota-neutral-40 dark:text-iota-neutral-60"> - <li>Connect your wallet to the IOTA Wallet Dashboard</li> - <li>Open the Vesting tab from the sidebar</li> - <li>Click See All to open the full vesting schedule</li> - <li> - Collect tokens as they unlock, or stake them directly from - the dashboard - </li> - </ol> - </div> - </Panel> + ></Banner> </div> </DialogBody> <div className="flex w-full flex-row justify-center gap-2 px-md--rs pb-md--rs pt-sm--rs"> - <Button onClick={navigateToDashboard} fullWidth text="Go to Dashboard" /> + <Button + onClick={navigateToDashboard} + fullWidth + text="Go to Dashboard" + icon={<ArrowTopRight />} + /> </div> </DialogContent> </Dialog> diff --git a/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx b/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx index c19e90775ac..c458a8a964e 100644 --- a/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx +++ b/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx @@ -283,6 +283,7 @@ export function TokenDetails() { onClick={() => setDialogMigrationOpen(true)} title="Migration" icon={Migration} + subtitle="Action required" /> ) : null} {hasSupplyIncreaseVestingObjects ? ( @@ -290,6 +291,7 @@ export function TokenDetails() { onClick={() => setDialogVestingOpen(true)} title="Vesting" icon={Vesting} + subtitle="Action required" /> ) : null} </div>