diff --git a/apps/core/src/utils/stake/createTimelockedUnstakeTransaction.ts b/apps/core/src/utils/stake/createTimelockedUnstakeTransaction.ts new file mode 100644 index 00000000000..79e618fef6a --- /dev/null +++ b/apps/core/src/utils/stake/createTimelockedUnstakeTransaction.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { TransactionBlock } from '@iota/iota-sdk/transactions'; +import { IOTA_SYSTEM_STATE_OBJECT_ID } from '@iota/iota-sdk/utils'; + +export function createTimelockedUnstakeTransaction(timelockedStakedObjectIds: string[]) { + const tx = new TransactionBlock(); + // TODO: check the max tx limit per ptb + for (const timelockedStakedObjectId of timelockedStakedObjectIds) { + tx.moveCall({ + target: `0x3::timelocked_staking::request_withdraw_stake`, + arguments: [ + tx.sharedObjectRef({ + objectId: IOTA_SYSTEM_STATE_OBJECT_ID, + initialSharedVersion: 1, + mutable: true, + }), + tx.object(timelockedStakedObjectId), + ], + }); + } + + return tx; +} diff --git a/apps/core/src/utils/stake/index.ts b/apps/core/src/utils/stake/index.ts index 7afbfdee3e8..1d03b413bff 100644 --- a/apps/core/src/utils/stake/index.ts +++ b/apps/core/src/utils/stake/index.ts @@ -4,3 +4,4 @@ export * from './createUnstakeTransaction'; export * from './formatDelegatedStake'; export * from './createStakeTransaction'; +export * from './createTimelockedUnstakeTransaction'; diff --git a/apps/wallet-dashboard/app/dashboard/vesting/page.tsx b/apps/wallet-dashboard/app/dashboard/vesting/page.tsx index 46c39c6c703..21c24456449 100644 --- a/apps/wallet-dashboard/app/dashboard/vesting/page.tsx +++ b/apps/wallet-dashboard/app/dashboard/vesting/page.tsx @@ -3,8 +3,8 @@ 'use client'; -import { Button } from '@/components'; -import { useGetCurrentEpochStartTimestamp, useNotifications } from '@/hooks'; +import { Button, TimelockedUnstakePopup } from '@/components'; +import { useGetCurrentEpochStartTimestamp, useNotifications, usePopups } from '@/hooks'; import { formatDelegatedTimelockedStake, getVestingOverview, @@ -26,14 +26,15 @@ import { useIotaClient, useSignAndExecuteTransactionBlock, } from '@iota/dapp-kit'; +import { IotaValidatorSummary } from '@iota/iota-sdk/client'; import { useQueryClient } from '@tanstack/react-query'; function VestingDashboardPage(): JSX.Element { const account = useCurrentAccount(); const queryClient = useQueryClient(); const iotaClient = useIotaClient(); - const { addNotification } = useNotifications(); + const { openPopup, closePopup } = usePopups(); const { data: currentEpochMs } = useGetCurrentEpochStartTimestamp(); const { data: activeValidators } = useGetActiveValidatorsInfo(); const { data: timelockedObjects } = useGetAllOwnedObjects(account?.address || '', { @@ -53,11 +54,9 @@ function VestingDashboardPage(): JSX.Element { Number(currentEpochMs), ); - function getValidatorName(validatorAddress: string): string { - return ( - activeValidators?.find( - (activeValidator) => activeValidator.iotaAddress === validatorAddress, - )?.name || validatorAddress + function getValidatorByAddress(validatorAddress: string): IotaValidatorSummary | undefined { + return activeValidators?.find( + (activeValidator) => activeValidator.iotaAddress === validatorAddress, ); } @@ -78,7 +77,7 @@ function VestingDashboardPage(): JSX.Element { }) .then(() => { queryClient.invalidateQueries({ - queryKey: ['get-staked-timelocked-objects', account?.address], + queryKey: ['get-timelocked-staked-objects', account?.address], }); queryClient.invalidateQueries({ queryKey: [ @@ -119,8 +118,21 @@ function VestingDashboardPage(): JSX.Element { }; function handleUnstake(delegatedTimelockedStake: TimelockedStakedObjectsGrouped): void { - // TODO: handle unstake logic - console.info('delegatedTimelockedStake', delegatedTimelockedStake); + const validatorInfo = getValidatorByAddress(delegatedTimelockedStake.validatorAddress); + if (!account || !validatorInfo) { + addNotification('Cannot create transaction', NotificationType.Error); + return; + } + + openPopup( + , + ); } return ( @@ -174,7 +186,8 @@ function VestingDashboardPage(): JSX.Element { > Validator:{' '} - {getValidatorName(timelockedStakedObject.validatorAddress)} + {getValidatorByAddress(timelockedStakedObject.validatorAddress) + ?.name || timelockedStakedObject.validatorAddress} Stake Request Epoch: {timelockedStakedObject.stakeRequestEpoch} @@ -188,17 +201,17 @@ function VestingDashboardPage(): JSX.Element { ); })} + {account?.address && ( +
+ {vestingSchedule.availableClaiming ? ( + + ) : null} + {vestingSchedule.availableStaking ? ( + + ) : null} +
+ )} - {account?.address && ( -
- {vestingSchedule.availableClaiming ? ( - - ) : null} - {vestingSchedule.availableStaking ? ( - - ) : null} -
- )} ); } diff --git a/apps/wallet-dashboard/components/Popup/Popups/VestingPopup/TimelockedUnstakePopup.tsx b/apps/wallet-dashboard/components/Popup/Popups/VestingPopup/TimelockedUnstakePopup.tsx new file mode 100644 index 00000000000..0c965b0b5d5 --- /dev/null +++ b/apps/wallet-dashboard/components/Popup/Popups/VestingPopup/TimelockedUnstakePopup.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { Button } from '@/components'; +import { useNotifications, useTimelockedUnstakeTransaction } from '@/hooks'; +import { useSignAndExecuteTransactionBlock } from '@iota/dapp-kit'; +import { IotaValidatorSummary } from '@iota/iota-sdk/client'; +import { NotificationType } from '@/stores/notificationStore'; +import { TimelockedStakedObjectsGrouped } from '@/lib/utils'; + +interface UnstakePopupProps { + accountAddress: string; + delegatedStake: TimelockedStakedObjectsGrouped; + validatorInfo: IotaValidatorSummary; + closePopup: () => void; + onSuccess?: (digest: string) => void; +} + +function TimelockedUnstakePopup({ + accountAddress, + delegatedStake, + validatorInfo, + closePopup, + onSuccess, +}: UnstakePopupProps): JSX.Element { + const objectIds = delegatedStake.stakes.map((stake) => stake.timelockedStakedIotaId); + const { data: timelockedUnstake } = useTimelockedUnstakeTransaction(objectIds, accountAddress); + const { mutateAsync: signAndExecuteTransactionBlock, isPending } = + useSignAndExecuteTransactionBlock(); + const { addNotification } = useNotifications(); + + async function handleTimelockedUnstake(): Promise { + if (!timelockedUnstake) return; + signAndExecuteTransactionBlock( + { + transactionBlock: timelockedUnstake.transaction, + }, + { + onSuccess: (tx) => { + if (onSuccess) { + onSuccess(tx.digest); + } + }, + }, + ) + .then(() => { + closePopup(); + addNotification('Unstake transaction has been sent'); + }) + .catch(() => { + addNotification('Unstake transaction was not sent', NotificationType.Error); + }); + } + + return ( +
+

Validator Name: {validatorInfo.name}

+

Validator Address: {delegatedStake.validatorAddress}

+

Stake Request Epoch: {delegatedStake.stakeRequestEpoch}

+

Rewards: {validatorInfo.rewardsPool}

+

Total Stakes: {delegatedStake.stakes.length}

+ {delegatedStake.stakes.map((stake, index) => { + return ( +
+ + Stake {index + 1}: {stake.timelockedStakedIotaId} + + Expiration time: {stake.expirationTimestampMs} + Label: {stake.label} + Status: {stake.status} +
+ ); + })} +

Gas Fees: {timelockedUnstake?.gasBudget?.toString() || '--'}

+ {isPending ? ( + + ) : ( + + )} +
+ ); +} + +export default TimelockedUnstakePopup; diff --git a/apps/wallet-dashboard/components/Popup/Popups/VestingPopup/index.ts b/apps/wallet-dashboard/components/Popup/Popups/VestingPopup/index.ts new file mode 100644 index 00000000000..2a8ca3b61df --- /dev/null +++ b/apps/wallet-dashboard/components/Popup/Popups/VestingPopup/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export { default as TimelockedUnstakePopup } from './TimelockedUnstakePopup'; diff --git a/apps/wallet-dashboard/components/Popup/Popups/index.ts b/apps/wallet-dashboard/components/Popup/Popups/index.ts index 9388fb78536..095ffa1220a 100644 --- a/apps/wallet-dashboard/components/Popup/Popups/index.ts +++ b/apps/wallet-dashboard/components/Popup/Popups/index.ts @@ -9,4 +9,5 @@ export { default as SendCoinPopup } from './SendCoinPopup/SendCoinPopup'; export { default as SendAssetPopup } from './SendAssetPopup'; export * from './SendCoinPopup'; +export * from './VestingPopup'; export * from './NewStakePopup'; diff --git a/apps/wallet-dashboard/hooks/index.ts b/apps/wallet-dashboard/hooks/index.ts index d331f08a80d..07ccfe796f8 100644 --- a/apps/wallet-dashboard/hooks/index.ts +++ b/apps/wallet-dashboard/hooks/index.ts @@ -8,3 +8,4 @@ export * from './useNotifications'; export * from './useSendCoinTransaction'; export * from './useCreateSendAssetTransaction'; export * from './useGetCurrentEpochStartTimestamp'; +export * from './useTimelockedUnstakeTransaction'; diff --git a/apps/wallet-dashboard/hooks/useTimelockedUnstakeTransaction.ts b/apps/wallet-dashboard/hooks/useTimelockedUnstakeTransaction.ts new file mode 100644 index 00000000000..c7d1152e2da --- /dev/null +++ b/apps/wallet-dashboard/hooks/useTimelockedUnstakeTransaction.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { createTimelockedUnstakeTransaction } from '@iota/core'; +import { useIotaClient } from '@iota/dapp-kit'; +import { useQuery } from '@tanstack/react-query'; + +export function useTimelockedUnstakeTransaction( + timelockedStakedObjectIds: string[], + senderAddress: string, +) { + const client = useIotaClient(); + return useQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: ['timelocked-unstake-transaction', timelockedStakedObjectIds, senderAddress], + queryFn: async () => { + const transaction = createTimelockedUnstakeTransaction(timelockedStakedObjectIds); + transaction.setSender(senderAddress); + await transaction.build({ client }); + return transaction; + }, + enabled: !!timelockedStakedObjectIds && !!senderAddress, + gcTime: 0, + select: (transaction) => { + return { + transaction, + gasBudget: transaction.blockData.gasConfig.budget, + }; + }, + }); +}