From 7602f4b1309d69e7855ed63f37e5f3e549ea85b5 Mon Sep 17 00:00:00 2001 From: fmtabbara Date: Tue, 12 Mar 2024 10:17:29 +0000 Subject: [PATCH] create delegations context and page --- .../components/Delegations/DelegateModal.tsx | 57 +---- explorer/src/context/delegations.tsx | 200 ++++++++++++++++ explorer/src/hooks/index.ts | 1 + explorer/src/icons/ElipsSVG.tsx | 2 +- explorer/src/pages/Delegations/index.tsx | 223 ++++++++++++++++++ explorer/src/routes/index.tsx | 2 + 6 files changed, 439 insertions(+), 46 deletions(-) create mode 100644 explorer/src/context/delegations.tsx create mode 100644 explorer/src/pages/Delegations/index.tsx diff --git a/explorer/src/components/Delegations/DelegateModal.tsx b/explorer/src/components/Delegations/DelegateModal.tsx index fc12f82664..9ebf719661 100644 --- a/explorer/src/components/Delegations/DelegateModal.tsx +++ b/explorer/src/components/Delegations/DelegateModal.tsx @@ -1,15 +1,15 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Box, SxProps } from '@mui/material'; import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'; import { CurrencyDenom, DecCoin } from '@nymproject/types'; -import { useChain } from '@cosmos-kit/react'; +import { useWalletContext } from '@src/context/wallet'; +import { useDelegationsContext } from '@src/context/delegations'; import { SimpleModal } from './SimpleModal'; import { ModalListItem } from './ModalListItem'; import { DelegationModalProps } from './DelegationModal'; -import { unymToNym, validateAmount } from '../../utils/currency'; +import { validateAmount } from '../../utils/currency'; import { urls } from '../../utils'; -import { NYM_MIXNET_CONTRACT } from '../../api/constants'; const MIN_AMOUNT_TO_DELEGATE = 10; @@ -31,25 +31,9 @@ export const DelegateModal: FCWithChildren<{ const [amount, setAmount] = useState({ amount: '10', denom: 'nym' }); const [isValidated, setValidated] = useState(false); const [errorAmount, setErrorAmount] = useState(); - const [balance, setBalance] = useState<{ - status: 'loading' | 'success'; - data?: string; - }>({ status: 'loading', data: undefined }); - const { address, getCosmWasmClient, getSigningCosmWasmClient } = useChain('nyx'); - - const getBalance = async (walletAddress: string) => { - const account = await getCosmWasmClient(); - const uNYMBalance = await account.getBalance(walletAddress, 'unym'); - const NYMBalance = unymToNym(uNYMBalance.amount); - - setBalance({ status: 'success', data: NYMBalance }); - }; - useEffect(() => { - if (address) { - getBalance(address); - } - }, [address]); + const { address, balance } = useWalletContext(); + const { handleDelegate } = useDelegationsContext(); const validate = async () => { let newValidatedValue = true; @@ -59,7 +43,6 @@ export const DelegateModal: FCWithChildren<{ newValidatedValue = false; errorAmountMessage = 'Please enter a valid amount'; } - console.log(amount, MIN_AMOUNT_TO_DELEGATE); if (amount && +amount.amount < MIN_AMOUNT_TO_DELEGATE) { errorAmountMessage = `Min. delegation amount: ${MIN_AMOUNT_TO_DELEGATE} ${denom.toUpperCase()}`; @@ -81,32 +64,13 @@ export const DelegateModal: FCWithChildren<{ const delegateToMixnode = async ({ delegationMixId, - delgationAddress, delegationAmount, }: { delegationMixId: number; - delgationAddress: string; delegationAmount: string; }) => { - const amountToDelegate = (Number(delegationAmount) * 1000000).toString(); - const uNymFunds = [{ amount: amountToDelegate, denom: 'unym' }]; - const memo: string = 'test delegation'; - const fee = { gas: '1000000', amount: [{ amount: '1000000', denom: 'unym' }] }; - try { - const signerClient = await getSigningCosmWasmClient(); - const tx = await signerClient.execute( - delgationAddress, - NYM_MIXNET_CONTRACT, - { - delegate_to_mixnode: { - mix_id: delegationMixId, - }, - }, - fee, - memo, - uNymFunds, - ); + const tx = await handleDelegate(delegationMixId, delegationAmount); return tx; } catch (e) { console.error('Failed to delegateToMixnode', e); @@ -126,10 +90,13 @@ export const DelegateModal: FCWithChildren<{ const tx = await delegateToMixnode({ delegationMixId: mixId, - delgationAddress: address, delegationAmount: amount.amount, }); + if (!tx) { + throw new Error('Failed to delegate'); + } + onOk({ status: 'success', message: 'This operation can take up to one hour to process', @@ -138,7 +105,7 @@ export const DelegateModal: FCWithChildren<{ ], }); } catch (e) { - console.error('Failed to addDelegation', e); + console.error('Failed to delegate', e); onOk({ status: 'error', message: (e as Error).message, diff --git a/explorer/src/context/delegations.tsx b/explorer/src/context/delegations.tsx new file mode 100644 index 0000000000..d77e967332 --- /dev/null +++ b/explorer/src/context/delegations.tsx @@ -0,0 +1,200 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { ExecuteResult } from '@cosmjs/cosmwasm-stargate'; +import { Delegation, PendingEpochEventKind } from '@nymproject/contract-clients/Mixnet.types'; +import { useWalletContext } from './wallet'; +import { useMainContext } from './main'; + +const fee = { gas: '1000000', amount: [{ amount: '1000000', denom: 'unym' }] }; + +export type PendingEvent = ReturnType; + +export type DelegationWithRewards = Delegation & { + rewards: string; + identityKey: string; + pending: PendingEvent; +}; + +const getEventsByAddress = (kind: PendingEpochEventKind, address: String) => { + if ('delegate' in kind && kind.delegate.owner === address) { + return { + kind: 'delegate' as const, + mixId: kind.delegate.mix_id, + amount: kind.delegate.amount, + }; + } + + if ('undelegate' in kind && kind.undelegate.owner === address) { + return { + kind: 'undelegate' as const, + mixId: kind.undelegate.mix_id, + }; + } + + return undefined; +}; + +interface DelegationsState { + delegations?: DelegationWithRewards[]; + handleGetDelegations: () => Promise; + handleDelegate: (mixId: number, amount: string) => Promise; + handleUndelegate: (mixId: number) => Promise; +} + +export const DelegationsContext = createContext({ + delegations: undefined, + handleGetDelegations: async () => { + throw new Error('Please connect your wallet'); + }, + handleDelegate: async () => { + throw new Error('Please connect your wallet'); + }, + handleUndelegate: async () => { + throw new Error('Please connect your wallet'); + }, +}); + +export const DelegationsProvider = ({ children }: { children: React.ReactNode }) => { + const [delegations, setDelegations] = useState(); + const { address, nymQueryClient, nymClient } = useWalletContext(); + const { fetchMixnodes } = useMainContext(); + + const handleGetPendingEvents = async () => { + if (!nymQueryClient) { + return undefined; + } + + if (!address) { + return undefined; + } + + const response = await nymQueryClient.getPendingEpochEvents({}); + const pendingEvents: PendingEvent[] = []; + + response.events.forEach((e) => { + const event = getEventsByAddress(e.event.kind, address); + if (event) { + pendingEvents.push(event); + } + }); + + return pendingEvents; + }; + + const handleGetDelegationRewards = async (mixId: number) => { + if (!nymQueryClient) { + return undefined; + } + + if (!address) { + return undefined; + } + + const response = await nymQueryClient.getPendingDelegatorReward({ address, mixId }); + + return response; + }; + + const handleGetDelegations = useCallback(async () => { + if (!nymQueryClient) { + setDelegations(undefined); + return undefined; + } + + if (!address) { + setDelegations(undefined); + return undefined; + } + + // Get all mixnodes - Required to get the identity key for each delegation + const mixnodes = await fetchMixnodes(); + + // Get delegations + const delegationsResponse = await nymQueryClient.getDelegatorDelegations({ delegator: address }); + + // Get rewards for each delegation + const rewardsResponse = await Promise.all( + delegationsResponse.delegations.map((d) => handleGetDelegationRewards(d.mix_id)), + ); + + // Get all pending events + const pendingEvents = await handleGetPendingEvents(); + + const delegationsWithRewards: DelegationWithRewards[] = []; + + // Merge delegations with rewards and pending events + delegationsResponse.delegations.forEach((d, index) => { + delegationsWithRewards.push({ + ...d, + pending: pendingEvents?.find((e) => (e?.mixId === d.mix_id ? e.kind : undefined)), + identityKey: mixnodes?.find((m) => m.mix_id === d.mix_id)?.mix_node.identity_key || '', + rewards: rewardsResponse[index]?.amount_earned_detailed || '0', + }); + }); + + // Add pending events that are not in the delegations list + pendingEvents?.forEach((e) => { + if (e && !delegationsWithRewards.find((d) => d.mix_id === e.mixId)) { + delegationsWithRewards.push({ + mix_id: e.mixId, + height: 0, + cumulative_reward_ratio: '0', + owner: address, + amount: { + amount: '0', + denom: 'unym', + }, + rewards: '0', + identityKey: mixnodes?.find((m) => m.mix_id === e.mixId)?.mix_node.identity_key || '', + pending: e, + }); + } + }); + + setDelegations(delegationsWithRewards); + + return undefined; + }, [address, nymQueryClient]); + + const handleDelegate = async (mixId: number, amount: string) => { + if (!address) { + throw new Error('Please connect your wallet'); + } + + const amountToDelegate = (Number(amount) * 1000000).toString(); + const uNymFunds = [{ amount: amountToDelegate, denom: 'unym' }]; + try { + const tx = await nymClient?.delegateToMixnode({ mixId }, fee, 'Delegation from Nym Explorer', uNymFunds); + + return tx; + } catch (e) { + console.error('Failed to delegate to mixnode', e); + throw e; + } + }; + + const handleUndelegate = async (mixId: number) => { + const tx = await nymClient?.undelegateFromMixnode({ mixId }, fee); + + return tx; + }; + + const contextValue: DelegationsState = useMemo( + () => ({ + delegations, + handleGetDelegations, + handleDelegate, + handleUndelegate, + }), + [delegations, handleGetDelegations], + ); + + return {children}; +}; + +export const useDelegationsContext = () => { + const context = useContext(DelegationsContext); + if (!context) { + throw new Error('useDelegationsContext must be used within a DelegationsProvider'); + } + return context; +}; diff --git a/explorer/src/hooks/index.ts b/explorer/src/hooks/index.ts index e2c94ec1e4..ba04855f77 100644 --- a/explorer/src/hooks/index.ts +++ b/explorer/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useIsMobile'; export * from './useIsMounted'; export * from './useGetMixnodeStatusColor'; +export * from './useNymClient'; diff --git a/explorer/src/icons/ElipsSVG.tsx b/explorer/src/icons/ElipsSVG.tsx index 6ced45169f..4a430d6cb6 100644 --- a/explorer/src/icons/ElipsSVG.tsx +++ b/explorer/src/icons/ElipsSVG.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; export const ElipsSVG: FCWithChildren = () => ( - + ({ + identity: delegation.identityKey, + mix_id: delegation.mix_id, + amount: `${unymToNym(delegation.amount.amount)} NYM`, + rewards: `${unymToNym(delegation.rewards)} NYM`, + id: index, + pending: delegation.pending, +}); + +const DelegationsPage = () => { + const [confirmationModalProps, setConfirmationModalProps] = React.useState(); + const [isLoading, setIsLoading] = React.useState(false); + + const { isWalletConnected } = useWalletContext(); + const { handleGetDelegations, handleUndelegate, delegations } = useDelegationsContext(); + const navigate = useNavigate(); + + useEffect(() => { + let timeoutId: NodeJS.Timeout; + + const fetchDelegations = async () => { + setIsLoading(true); + try { + await handleGetDelegations(); + } catch (error) { + setConfirmationModalProps({ + status: 'error', + message: "Couldn't fetch delegations. Please try again later.", + }); + } finally { + setIsLoading(false); + + timeoutId = setTimeout(() => { + fetchDelegations(); + }, 60_000); + } + }; + + fetchDelegations(); + + return () => { + clearTimeout(timeoutId); + }; + }, [handleGetDelegations]); + + const getTooltipTitle = (pending: PendingEvent) => { + if (pending?.kind === 'undelegate') { + return 'You have an undelegation pending'; + } + + if (pending?.kind === 'delegate') { + return `You have a delegation pending worth ${unymToNym(pending.amount.amount)} NYM`; + } + + return undefined; + }; + + const onUndelegate = async (mixId: number) => { + setConfirmationModalProps({ status: 'loading' }); + + try { + const tx = await handleUndelegate(mixId); + + if (tx) { + setConfirmationModalProps({ + status: 'success', + message: 'Undelegation successful', + transactions: [{ url: `${tx.transactionHash}`, hash: tx.transactionHash }], + }); + } + } catch (error) { + if (error instanceof Error) { + setConfirmationModalProps({ status: 'error', message: error.message }); + } + } + }; + + const columns: GridColDef[] = [ + { + field: 'identity', + headerName: 'Identity Key', + width: 400, + disableColumnMenu: true, + disableReorder: true, + sortable: false, + headerAlign: 'left', + }, + { + field: 'mix_id', + headerName: 'Mix ID', + width: 150, + disableColumnMenu: true, + disableReorder: true, + sortable: false, + headerAlign: 'left', + }, + { + field: 'amount', + headerName: 'Amount', + width: 150, + disableColumnMenu: true, + disableReorder: true, + sortable: false, + headerAlign: 'left', + }, + { + field: 'rewards', + headerName: 'Rewards', + width: 150, + disableColumnMenu: true, + disableReorder: true, + sortable: false, + headerAlign: 'left', + }, + { + field: 'undelegate', + headerName: '', + minWidth: 150, + flex: 1, + disableColumnMenu: true, + disableReorder: true, + sortable: false, + headerAlign: 'right', + renderCell: (params) => { + const { pending } = params.row; + + return ( + + {pending ? ( + e.stopPropagation()} + PopperProps={{}} + > + + + ) : ( + + )} + + ); + }, + }, + ]; + + const handleRowClick = (params: any) => { + navigate(`/network-components/mixnode/${params.row.mix_id}`); + }; + + return ( + + {confirmationModalProps && ( + { + await handleGetDelegations(); + setConfirmationModalProps(undefined); + }} + /> + )} + + + + + {!isWalletConnected ? ( + + + Connect your wallet to view your delegations. + + + + ) : null} + + + + + ); +}; + +export const Delegations = () => ( + + + +); diff --git a/explorer/src/routes/index.tsx b/explorer/src/routes/index.tsx index 57e015495d..67ef8b4621 100644 --- a/explorer/src/routes/index.tsx +++ b/explorer/src/routes/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Routes as ReactRouterRoutes, Route } from 'react-router-dom'; +import { Delegations } from '@src/pages/Delegations'; import { PageOverview } from '../pages/Overview'; import { PageMixnodesMap } from '../pages/MixnodesMap'; import { Page404 } from '../pages/404'; @@ -10,6 +11,7 @@ export const Routes: FCWithChildren = () => ( } /> } /> } /> + } /> } /> );