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 @@ -6,3 +6,4 @@ export * from './recognizedPackages.constants';
export * from './coins.constants';
export * from './timelock.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,5 +37,7 @@ export * from './useUnlockTimelockedObjectsTransaction';
export * from './useGetAllOwnedObjects';
export * from './useGetTimelockedStakedObjects';
export * from './useGetActiveValidatorsInfo';
export * from './useTransactionData';
export * from './useGetStakingValidatorDetails';

export * from './stake';
87 changes: 87 additions & 0 deletions apps/core/src/hooks/useGetStakingValidatorDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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 { useMemo } from 'react';
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 = useMemo(() => {
if (!system) return null;
return system.activeValidators.find((av) => av.iotaAddress === validatorAddress);
}, [validatorAddress, systemDataResult]);
marc2332 marked this conversation as resolved.
Show resolved Hide resolved

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

const totalStake = useMemo(() => {
if (!stakeData) return 0n;
return unstake
? getStakeIotaByIotaId(stakeData, stakeId)
: getTokenStakeIotaForValidator(stakeData, validatorAddress);
}, [stakeData, stakeId, unstake, validatorAddress]);

const totalValidatorsStake = useMemo(() => {
if (!system) return 0;
return system.activeValidators.reduce(
(acc, curr) => (acc += BigInt(curr.stakingPoolIotaBalance)),
0n,
);
}, [systemDataResult]);

const totalStakePercentage = useMemo(() => {
if (!systemDataResult || !validatorData) return null;

return calculateStakeShare(
BigInt(validatorData.stakingPoolIotaBalance),
BigInt(totalValidatorsStake),
);
}, [systemDataResult, totalValidatorsStake, validatorData]);
marc2332 marked this conversation as resolved.
Show resolved Hide resolved

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

return {
epoch: Number(system?.epoch) ?? 0,
marc2332 marked this conversation as resolved.
Show resolved Hide resolved
totalStake: useFormatCoin(totalStake, IOTA_TYPE_ARG),
totalValidatorsStake: useFormatCoin(totalValidatorStake, IOTA_TYPE_ARG),
totalStakePercentage,
validatorApy,
systemDataResult,
delegatedStakeDataResult,
};
marc2332 marked this conversation as resolved.
Show resolved Hide resolved
}
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,
};
}
19 changes: 19 additions & 0 deletions apps/core/src/utils/getDelegationDataByStakeId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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,
) {
let stake: StakeObject | null = null;
for (const { stakes } of delegationsStake) {
stake = stakes.find(({ stakedIotaId }) => stakedIotaId === stakeIotaId) || null;
if (stake) return stake;
}

return stake;
marc2332 marked this conversation as resolved.
Show resolved Hide resolved
}
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
15 changes: 11 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/StakeDetails';
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,12 @@ function StakingDashboardPage(): JSX.Element {
<Button onClick={handleNewStake}>New Stake</Button>
</div>
<StakeDialog isOpen={isDialogStakeOpen} setOpen={setIsDialogStakeOpen} />;
{selectedStake && (
<StakeDetailsDialog
extendedStake={selectedStake}
handleClose={() => setSelectedStake(null)}
/>
)}
</>
);
}
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 { StakeDetailsView } 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 StakeDetailsDialogProps {
extendedStake: ExtendedDelegatedStake;
showActiveStatus?: boolean;
handleClose: () => void;
}
export function StakeDetailsDialog({
extendedStake,
showActiveStatus,
handleClose,
}: StakeDetailsDialogProps) {
const [open, setOpen] = useState(true);

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

const [currentView, setCurrentView] = useState<DialogView>(
marc2332 marked this conversation as resolved.
Show resolved Hide resolved
VIEWS[DialogViewIdentifier.StakeDetails],
);

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,40 @@
// 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 StakeDetailsPopupProps {
extendedStake: ExtendedDelegatedStake;
onUnstake: () => void;
}

export function StakeDetailsView({
marc2332 marked this conversation as resolved.
Show resolved Hide resolved
extendedStake,
onUnstake,
}: StakeDetailsPopupProps): 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