Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(wallet-dashboard): add unstaking confirmation dialog #3917

4 changes: 4 additions & 0 deletions apps/core/src/constants/gas.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export const GAS_SYMBOL = 'IOTA';
1 change: 1 addition & 0 deletions apps/core/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './coins.constants';
export * from './timelock.constants';
export * from './migration.constants';
export * from './features.enum';
export * from './gas.constants';
2 changes: 2 additions & 0 deletions apps/core/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export * from './useUnlockTimelockedObjectsTransaction';
export * from './useGetAllOwnedObjects';
export * from './useGetTimelockedStakedObjects';
export * from './useGetActiveValidatorsInfo';
export * from './useTransactionData';
export * from './useGetStakingValidatorDetails';
export * from './useCursorPagination';

export * from './stake';
84 changes: 84 additions & 0 deletions apps/core/src/hooks/useGetStakingValidatorDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { useIotaClientQuery } from '@iota/dapp-kit';
import { useGetDelegatedStake } from './stake';
import { useGetValidatorsApy } from './useGetValidatorsApy';
import {
DELEGATED_STAKES_QUERY_REFETCH_INTERVAL,
DELEGATED_STAKES_QUERY_STALE_TIME,
} from '../constants';
import { calculateStakeShare, getStakeIotaByIotaId, getTokenStakeIotaForValidator } from '../utils';
import { useFormatCoin } from './useFormatCoin';
import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils';

interface UseGetStakingValidatorDetailsArgs {
accountAddress: string | null;
stakeId: string | null;
validatorAddress: string;
unstake?: boolean;
}

export function useGetStakingValidatorDetails({
accountAddress,
stakeId,
validatorAddress,
unstake,
}: UseGetStakingValidatorDetailsArgs) {
const systemDataResult = useIotaClientQuery('getLatestIotaSystemState');

const delegatedStakeDataResult = useGetDelegatedStake({
address: accountAddress || '',
staleTime: DELEGATED_STAKES_QUERY_STALE_TIME,
refetchInterval: DELEGATED_STAKES_QUERY_REFETCH_INTERVAL,
});

const { data: rollingAverageApys } = useGetValidatorsApy();
const { data: system } = systemDataResult;
const { data: stakeData } = delegatedStakeDataResult;

const validatorData = system?.activeValidators.find(
(av) => av.iotaAddress === validatorAddress,
);

//TODO: verify this is the correct validator stake balance
const totalValidatorStake = validatorData?.stakingPoolIotaBalance || 0;

const totalStake = !stakeData
? 0n
: unstake
? getStakeIotaByIotaId(stakeData, stakeId)
: getTokenStakeIotaForValidator(stakeData, validatorAddress);

const totalValidatorsStake =
system?.activeValidators.reduce(
(acc, curr) => (acc += BigInt(curr.stakingPoolIotaBalance)),
0n,
) ?? 0n;

const totalStakePercentage =
!systemDataResult || !validatorData
? null
: calculateStakeShare(
BigInt(validatorData.stakingPoolIotaBalance),
BigInt(totalValidatorsStake),
);

const validatorApy = rollingAverageApys?.[validatorAddress] ?? {
apy: null,
isApyApproxZero: undefined,
};

const totalStakeFormatted = useFormatCoin(totalStake, IOTA_TYPE_ARG);
const totalValidatorsStakeFormatted = useFormatCoin(totalValidatorStake, IOTA_TYPE_ARG);

return {
epoch: Number(system?.epoch) || 0,
totalStake: totalStakeFormatted,
totalValidatorsStake: totalValidatorsStakeFormatted,
totalStakePercentage,
validatorApy,
systemDataResult,
delegatedStakeDataResult,
};
}
38 changes: 38 additions & 0 deletions apps/core/src/hooks/useTransactionData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) Mysten Labs, Inc.
// Modifications Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { useFormatCoin } from '.';
import { useIotaClient } from '@iota/dapp-kit';
import { Transaction, type TransactionData } from '@iota/iota-sdk/transactions';
import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils';
import { useQuery } from '@tanstack/react-query';

export function useTransactionData(sender?: string | null, transaction?: Transaction | null) {
const client = useIotaClient();
return useQuery<TransactionData>({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: ['transaction-data', transaction?.getData()],
queryFn: async () => {
const clonedTransaction = Transaction.from(transaction!);
if (sender) {
clonedTransaction.setSenderIfNotSet(sender);
}
// Build the transaction to bytes, which will ensure that the transaction data is fully populated:
await clonedTransaction!.build({ client });
return clonedTransaction!.getData();
},
enabled: !!transaction,
});
}

export function useTransactionGasBudget(sender?: string | null, transaction?: Transaction | null) {
const { data, ...rest } = useTransactionData(sender, transaction);

const [formattedGas] = useFormatCoin(data?.gasData.budget, IOTA_TYPE_ARG);

return {
data: formattedGas,
...rest,
};
}
18 changes: 18 additions & 0 deletions apps/core/src/utils/getDelegationDataByStakeId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Mysten Labs, Inc.
// Modifications Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import type { DelegatedStake, StakeObject } from '@iota/iota-sdk/client';

// Helper function to get the delegation by stakedIotaId
export function getDelegationDataByStakeId(
delegationsStake: DelegatedStake[],
stakeIotaId: string,
): StakeObject | null {
for (const { stakes } of delegationsStake) {
const stake = stakes.find(({ stakedIotaId }) => stakedIotaId === stakeIotaId) || null;
if (stake) return stake;
}

return null;
}
3 changes: 3 additions & 0 deletions apps/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export * from './filterAndSortTokenBalances';
export * from './getOwnerDisplay';
export * from './parseAmount';
export * from './parseObjectDetails';
export * from './getStakeIotaByIotaId';
export * from './getTokenStakeIotaForValidator';
export * from './getDelegationDataByStakeId';

export * from './stake';
export * from './transaction';
Expand Down
16 changes: 12 additions & 4 deletions apps/wallet-dashboard/app/(protected)/staking/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

'use client';

import { AmountBox, Box, StakeCard, StakeDialog, StakeDetailsPopup, Button } from '@/components';
import { usePopups } from '@/hooks';
import { AmountBox, Box, StakeCard, StakeDialog, Button } from '@/components';
import { StakeDetailsDialog } from '@/components/Dialogs';
import {
ExtendedDelegatedStake,
formatDelegatedStake,
Expand All @@ -22,8 +22,8 @@ import { useState } from 'react';
function StakingDashboardPage(): JSX.Element {
const account = useCurrentAccount();
const [isDialogStakeOpen, setIsDialogStakeOpen] = useState(false);
const [selectedStake, setSelectedStake] = useState<ExtendedDelegatedStake | null>(null);

const { openPopup, closePopup } = usePopups();
const { data: delegatedStakeData } = useGetDelegatedStake({
address: account?.address || '',
staleTime: DELEGATED_STAKES_QUERY_STALE_TIME,
Expand All @@ -43,8 +43,9 @@ function StakingDashboardPage(): JSX.Element {
);

const viewStakeDetails = (extendedStake: ExtendedDelegatedStake) => {
openPopup(<StakeDetailsPopup extendedStake={extendedStake} onClose={closePopup} />);
setSelectedStake(extendedStake);
};

function handleNewStake() {
setIsDialogStakeOpen(true);
}
Expand Down Expand Up @@ -79,6 +80,13 @@ function StakingDashboardPage(): JSX.Element {
<Button onClick={handleNewStake}>New Stake</Button>
</div>
<StakeDialog isOpen={isDialogStakeOpen} setOpen={setIsDialogStakeOpen} />;
{selectedStake && (
<StakeDetailsDialog
extendedStake={selectedStake}
handleClose={() => setSelectedStake(null)}
showActiveStatus
/>
)}
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { DialogView } from '@/lib/interfaces';
import { StakeDialogView } from './views';
import { useState } from 'react';
import { ExtendedDelegatedStake } from '@iota/core';
import { Dialog, DialogBody, DialogContent, DialogPosition, Header } from '@iota/apps-ui-kit';
import { UnstakeDialogView } from '../Unstake';

enum DialogViewIdentifier {
StakeDetails = 'StakeDetails',
Unstake = 'Unstake',
}

interface StakeDetailsProps {
extendedStake: ExtendedDelegatedStake;
showActiveStatus?: boolean;
handleClose: () => void;
}

export function StakeDetailsDialog({
extendedStake,
showActiveStatus,
handleClose,
}: StakeDetailsProps) {
const [open, setOpen] = useState(true);
const [currentViewId, setCurrentViewId] = useState<DialogViewIdentifier>(
DialogViewIdentifier.StakeDetails,
);

const VIEWS: Record<DialogViewIdentifier, DialogView> = {
[DialogViewIdentifier.StakeDetails]: {
header: <Header title="Stake Details" onClose={handleClose} />,
body: (
<StakeDialogView
extendedStake={extendedStake}
onUnstake={() => setCurrentViewId(DialogViewIdentifier.Unstake)}
/>
),
},
[DialogViewIdentifier.Unstake]: {
header: <Header title="Unstake" onClose={handleClose} />,
body: (
<UnstakeDialogView
extendedStake={extendedStake}
handleClose={handleClose}
showActiveStatus={showActiveStatus}
/>
),
},
};

const currentView = VIEWS[currentViewId];

return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
setOpen(open);
}}
>
<DialogContent containerId="overlay-portal-container" position={DialogPosition.Right}>
{currentView.header}
<div className="flex h-full [&>div]:flex [&>div]:flex-1 [&>div]:flex-col">
<DialogBody>{currentView.body}</DialogBody>
</div>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export * from './StakeDetailsDialog';
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import React from 'react';
import { Button } from '@/components';
import { ExtendedDelegatedStake } from '@iota/core';

interface StakeDialogProps {
extendedStake: ExtendedDelegatedStake;
onUnstake: () => void;
}

export function StakeDialogView({ extendedStake, onUnstake }: StakeDialogProps): JSX.Element {
return (
<>
<div className="flex w-full max-w-[336px] flex-1 flex-col">
<div className="flex w-full max-w-full flex-1 flex-col gap-2 overflow-auto">
<p>Stake ID: {extendedStake.stakedIotaId}</p>
<p>Validator: {extendedStake.validatorAddress}</p>
<p>Stake: {extendedStake.principal}</p>
<p>Stake Active Epoch: {extendedStake.stakeActiveEpoch}</p>
<p>Stake Request Epoch: {extendedStake.stakeRequestEpoch}</p>
{extendedStake.status === 'Active' && (
<p>Estimated reward: {extendedStake.estimatedReward}</p>
)}
<p>Status: {extendedStake.status}</p>
</div>
</div>
<div className="flex justify-between gap-2">
<Button onClick={onUnstake} disabled={extendedStake.status !== 'Active'}>
Unstake
</Button>
<Button onClick={() => console.log('Stake more')}>Stake more</Button>
</div>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export * from './StakeDetailsView';
4 changes: 4 additions & 0 deletions apps/wallet-dashboard/components/Dialogs/Unstake/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export * from './views';
Loading
Loading