create delegations context and page

This commit is contained in:
fmtabbara
2024-03-12 10:17:29 +00:00
parent f762062f9f
commit 7602f4b130
6 changed files with 439 additions and 46 deletions
@@ -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<DecCoin | undefined>({ amount: '10', denom: 'nym' });
const [isValidated, setValidated] = useState<boolean>(false);
const [errorAmount, setErrorAmount] = useState<string | undefined>();
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,
+200
View File
@@ -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<typeof getEventsByAddress>;
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<void>;
handleDelegate: (mixId: number, amount: string) => Promise<ExecuteResult | undefined>;
handleUndelegate: (mixId: number) => Promise<ExecuteResult | undefined>;
}
export const DelegationsContext = createContext<DelegationsState>({
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<DelegationWithRewards[]>();
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 <DelegationsContext.Provider value={contextValue}>{children}</DelegationsContext.Provider>;
};
export const useDelegationsContext = () => {
const context = useContext(DelegationsContext);
if (!context) {
throw new Error('useDelegationsContext must be used within a DelegationsProvider');
}
return context;
};
+1
View File
@@ -1,3 +1,4 @@
export * from './useIsMobile';
export * from './useIsMounted';
export * from './useGetMixnodeStatusColor';
export * from './useNymClient';
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from 'react';
export const ElipsSVG: FCWithChildren = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="25" viewBox="0 0 24 25" fill="none">
<circle cx="12" cy="12.5" r="12" fill="url(#paint0_angular_2549_7570)" />
<circle cx="12" cy="12.5" r="10" fill="url(#paint0_angular_2549_7570)" />
<defs>
<radialGradient
id="paint0_angular_2549_7570"
+223
View File
@@ -0,0 +1,223 @@
import React, { useEffect } from 'react';
import { Box, Button, Card, Chip, Tooltip, Typography } from '@mui/material';
import { Link, useNavigate } from 'react-router-dom';
import { DelegationModal, DelegationModalProps, Title, UniversalDataGrid } from '@src/components';
import { useWalletContext } from '@src/context/wallet';
import { ConnectKeplrWallet } from '@src/components/Wallet/ConnectKeplrWallet';
import { GridColDef } from '@mui/x-data-grid';
import { unymToNym } from '@src/utils/currency';
import {
DelegationWithRewards,
DelegationsProvider,
PendingEvent,
useDelegationsContext,
} from '@src/context/delegations';
const mapToDelegationsRow = (delegation: DelegationWithRewards, index: number) => ({
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<DelegationModalProps | undefined>();
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 (
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'end' }}>
{pending ? (
<Tooltip
placement="left"
title={getTooltipTitle(pending as PendingEvent)}
onClick={(e) => e.stopPropagation()}
PopperProps={{}}
>
<Chip size="small" label="Pending events" />
</Tooltip>
) : (
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.stopPropagation();
onUndelegate(params.row.mix_id);
}}
>
Undelegate
</Button>
)}
</Box>
);
},
},
];
const handleRowClick = (params: any) => {
navigate(`/network-components/mixnode/${params.row.mix_id}`);
};
return (
<Box>
{confirmationModalProps && (
<DelegationModal
{...confirmationModalProps}
open={Boolean(confirmationModalProps)}
onClose={async () => {
await handleGetDelegations();
setConfirmationModalProps(undefined);
}}
/>
)}
<Box display="flex" justifyContent="space-between" alignItems="center">
<Title text="Your Delegations" />
<Button variant="contained" color="primary" component={Link} to="/network-components/mixnodes">
Delegate
</Button>
</Box>
{!isWalletConnected ? (
<Box>
<Typography mb={2} variant="h6">
Connect your wallet to view your delegations.
</Typography>
<ConnectKeplrWallet />
</Box>
) : null}
<Card
sx={{
mt: 2,
padding: 2,
height: '100%',
}}
>
<UniversalDataGrid
onRowClick={handleRowClick}
rows={delegations?.map(mapToDelegationsRow) || []}
columns={columns}
loading={isLoading}
/>
</Card>
</Box>
);
};
export const Delegations = () => (
<DelegationsProvider>
<DelegationsPage />
</DelegationsProvider>
);
+2
View File
@@ -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 = () => (
<Route path="/" element={<PageOverview />} />
<Route path="/network-components/*" element={<NetworkComponentsRoutes />} />
<Route path="/nodemap" element={<PageMixnodesMap />} />
<Route path="/delegations" element={<Delegations />} />
<Route path="*" element={<Page404 />} />
</ReactRouterRoutes>
);