Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e68a2c2ae3 | |||
| e945c94afc | |||
| 641b8179ba | |||
| df4385ab71 | |||
| 777166b93d | |||
| ccd889fc38 | |||
| d9291df347 | |||
| 5fcce2de48 | |||
| abf9ccb823 | |||
| 352527a098 | |||
| 724888e790 | |||
| 4b552db19f | |||
| 75aa2579a0 | |||
| 2493abcdff | |||
| 508f8324f9 | |||
| 468d0f38e9 | |||
| 3382642d70 | |||
| 8d3f1a3c38 | |||
| c5e695f8b5 | |||
| 80cfe83f9d | |||
| 76a22035be | |||
| 79bc2ab493 | |||
| 8c2c0f8033 | |||
| 5deb0875e2 | |||
| d1ee6faca8 | |||
| 756bad977f | |||
| b6448691ce |
@@ -0,0 +1,44 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { MoreVertSharp } from '@mui/icons-material';
|
||||
import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem } from '@mui/material';
|
||||
|
||||
export const ActionsMenu: React.FC<{ open: boolean; onOpen: () => void; onClose: () => void }> = ({
|
||||
children,
|
||||
open,
|
||||
onOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const anchorEl: any = useRef<HTMLElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton ref={anchorEl} onClick={onOpen}>
|
||||
<MoreVertSharp />
|
||||
</IconButton>
|
||||
<Menu anchorEl={anchorEl.current} open={open} onClose={onClose}>
|
||||
{children}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActionsMenuItem = ({
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
Icon,
|
||||
disabled,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
Icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<MenuItem sx={{ p: 2 }} onClick={onClick} disabled={disabled}>
|
||||
<ListItemIcon sx={{ color: 'text.primary' }}>{Icon}</ListItemIcon>
|
||||
<ListItemText sx={{ color: 'text.primary' }} primary={title} secondary={description} />
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
// TODO
|
||||
@@ -36,7 +36,7 @@ export const CopyToClipboard = ({ text = '', iconButton }: { text?: string; icon
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{!copied ? <ContentCopy fontSize="small" /> : <Check color="success" />}
|
||||
{!copied ? <ContentCopy sx={{ fontSize: 14 }} /> : <Check color="success" sx={{ fontSize: 14 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -219,6 +219,7 @@ export const DelegateModal: React.FC<{
|
||||
initialValue={amount}
|
||||
autoFocus={Boolean(initialIdentityKey)}
|
||||
onChanged={handleAmountChanged}
|
||||
denom={currency}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { MoreVertSharp } from '@mui/icons-material';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, ListItemIcon, ListItemText, MenuItem, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { DelegationEventKind } from '@nymproject/types';
|
||||
import { Delegate, Undelegate } from '../../svg-icons';
|
||||
import { ActionsMenu, ActionsMenuItem } from '../ActionsMenu';
|
||||
import { DelegateListItemPending } from './types';
|
||||
|
||||
export type DelegationListItemActions = 'delegate' | 'undelegate' | 'redeem' | 'compound';
|
||||
@@ -100,17 +89,14 @@ export const DelegationsActionsMenu: React.FC<{
|
||||
disableRedeemingRewards?: boolean;
|
||||
disableCompoundRewards?: boolean;
|
||||
}> = ({ disableRedeemingRewards, disableCompoundRewards, onActionClick, isPending }) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleClose = () => setAnchorEl(null);
|
||||
const handleOpenMenu = () => setIsOpen(true);
|
||||
const handleOnClose = () => setIsOpen(false);
|
||||
|
||||
const handleActionSelect = (action: DelegationListItemActions) => {
|
||||
handleClose();
|
||||
onActionClick?.(action);
|
||||
handleOnClose();
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
@@ -126,37 +112,28 @@ export const DelegationsActionsMenu: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={handleClick}>
|
||||
<MoreVertSharp />
|
||||
</IconButton>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||
<DelegationActionsMenuItem
|
||||
title="Delegate more"
|
||||
Icon={<Delegate />}
|
||||
onClick={() => handleActionSelect?.('delegate')}
|
||||
/>
|
||||
<DelegationActionsMenuItem
|
||||
<ActionsMenu open={isOpen} onOpen={handleOpenMenu} onClose={handleOnClose}>
|
||||
<ActionsMenuItem title="Delegate more" Icon={<Delegate />} onClick={() => handleActionSelect('delegate')} />
|
||||
<ActionsMenuItem
|
||||
title="Undelegate"
|
||||
Icon={<Undelegate />}
|
||||
onClick={() => handleActionSelect?.('undelegate')}
|
||||
onClick={() => handleActionSelect('undelegate')}
|
||||
disabled={false}
|
||||
/>
|
||||
<DelegationActionsMenuItem
|
||||
<ActionsMenuItem
|
||||
title="Redeem"
|
||||
description="Trasfer your rewards to your balance"
|
||||
Icon={<Typography sx={{ pl: 1 }}>R</Typography>}
|
||||
onClick={() => handleActionSelect?.('redeem')}
|
||||
onClick={() => handleActionSelect('redeem')}
|
||||
disabled={disableRedeemingRewards}
|
||||
/>
|
||||
<DelegationActionsMenuItem
|
||||
<ActionsMenuItem
|
||||
title="Compound"
|
||||
description="Add your rewards to this delegation"
|
||||
Icon={<Typography sx={{ pl: 1 }}>C</Typography>}
|
||||
onClick={() => handleActionSelect?.('compound')}
|
||||
onClick={() => handleActionSelect('compound')}
|
||||
disabled={disableCompoundRewards}
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
</ActionsMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { CopyToClipboard } from '@nymproject/react/clipboard/CopyToClipboard';
|
||||
import { splice } from 'src/utils';
|
||||
|
||||
export const IdentityKey = ({ identityKey }: { identityKey: string }) => (
|
||||
<Stack direction="row">
|
||||
<Typography variant="body2" component="span" fontWeight={400} sx={{ mr: 1, color: 'text.primary' }}>
|
||||
{splice(6, identityKey)}
|
||||
</Typography>
|
||||
<CopyToClipboard value={identityKey} sx={{ fontSize: 18 }} />
|
||||
</Stack>
|
||||
);
|
||||
@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import { AccountBalanceWalletOutlined, ArrowBack, ArrowForward, Description, Settings } from '@mui/icons-material';
|
||||
import { AppContext } from '../context/main';
|
||||
import { Bond, Delegate, Unbond } from '../svg-icons';
|
||||
import { Bond, Delegate, Unbond, Bonding } from '../svg-icons';
|
||||
|
||||
export const Nav = () => {
|
||||
const location = useLocation();
|
||||
@@ -29,6 +29,12 @@ export const Nav = () => {
|
||||
Icon: ArrowBack,
|
||||
onClick: () => navigate('/receive'),
|
||||
},
|
||||
{
|
||||
label: 'Bonding',
|
||||
route: '/bonding',
|
||||
Icon: Bond,
|
||||
onClick: () => navigate('/bonding'),
|
||||
},
|
||||
{
|
||||
label: 'Bond',
|
||||
route: '/bond',
|
||||
|
||||
@@ -30,6 +30,7 @@ export const SendInput = () => {
|
||||
<SendInputModal
|
||||
toAddress=""
|
||||
fromAddress="nymt1w8qp7zsxggvtxhpqpt6e329j42wtv07dm5ts8u"
|
||||
denom="NYM"
|
||||
onNext={() => {}}
|
||||
onClose={() => {}}
|
||||
onAddressChange={() => {}}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const SendInputModal = ({
|
||||
amount,
|
||||
balance,
|
||||
error,
|
||||
denom,
|
||||
onNext,
|
||||
onClose,
|
||||
onAmountChange,
|
||||
@@ -25,6 +26,7 @@ export const SendInputModal = ({
|
||||
amount?: DecCoin;
|
||||
balance?: string;
|
||||
error?: string;
|
||||
denom: string;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
onAmountChange: (value: DecCoin) => void;
|
||||
@@ -69,6 +71,7 @@ export const SendInputModal = ({
|
||||
validate(value);
|
||||
}}
|
||||
initialValue={amount?.amount}
|
||||
denom={denom}
|
||||
/>
|
||||
<Typography fontSize="smaller" sx={{ color: 'error.main' }}>
|
||||
{error}
|
||||
|
||||
@@ -89,6 +89,7 @@ export const SendModal = ({ onClose, hasStorybookStyles }: { onClose: () => void
|
||||
error={error}
|
||||
onAmountChange={(value) => setAmount(value)}
|
||||
onAddressChange={(value) => setToAddress(value)}
|
||||
denom={denom}
|
||||
{...hasStorybookStyles}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { FeeDetails, DecCoin, MixnodeStatus, TransactionExecuteResult } from '@nymproject/types';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { Network } from 'src/types';
|
||||
import { TBondGatewayArgs, TBondMixNodeArgs } from 'src/types';
|
||||
import {
|
||||
bondGateway as bondGatewayRequest,
|
||||
bondMixNode as bondMixNodeRequest,
|
||||
claimOperatorRewards,
|
||||
compoundOperatorRewards,
|
||||
simulateBondGateway,
|
||||
simulateBondMixnode,
|
||||
simulateUnbondGateway,
|
||||
simulateUnbondMixnode,
|
||||
simulateVestingBondGateway,
|
||||
simulateVestingBondMixnode,
|
||||
simulateVestingUnbondGateway,
|
||||
simulateVestingUnbondMixnode,
|
||||
simulateUpdateMixnode,
|
||||
simulateVestingUpdateMixnode,
|
||||
unbondGateway as unbondGatewayRequest,
|
||||
unbondMixNode as unbondMixnodeRequest,
|
||||
vestingBondGateway,
|
||||
vestingBondMixNode,
|
||||
vestingUnbondGateway,
|
||||
vestingUnbondMixnode,
|
||||
updateMixnode as updateMixnodeRequest,
|
||||
vestingUpdateMixnode as updateMixnodeVestingRequest,
|
||||
getGatewayBondDetails,
|
||||
getMixnodeBondDetails,
|
||||
getMixnodeStatus,
|
||||
} from '../requests';
|
||||
import { useGetFee } from '../hooks/useGetFee';
|
||||
import { useCheckOwnership } from '../hooks/useCheckOwnership';
|
||||
import { AppContext } from './main';
|
||||
|
||||
const bounded: BondedMixnode = {
|
||||
identityKey: 'B2Xx4haarLWMajX8w259oHjtRZsC7nHwagbWrJNiA3QC',
|
||||
bond: { denom: 'nym', amount: '1234' },
|
||||
delegators: 123,
|
||||
ip: '1.2.34.5',
|
||||
nodeRewards: { denom: 'nym', amount: '123' },
|
||||
operatorRewards: { denom: 'nym', amount: '12' },
|
||||
profitMargin: 10,
|
||||
stake: { denom: 'nym', amount: '99' },
|
||||
stakeSaturation: 99,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
/* const bounded: BondedMixnode | BondedGateway = {
|
||||
bond: { denom: 'nym', amount: '1234' },
|
||||
identityKey: 'B2Xx4haarLWMajX8w259oHjtRZsC7nHwagbWrJNiA3QC',
|
||||
ip: '1.2.34.5',
|
||||
location: 'France',
|
||||
}; */
|
||||
|
||||
// TODO add relevant data
|
||||
export interface BondedMixnode {
|
||||
identityKey: string;
|
||||
ip: string;
|
||||
stake: DecCoin;
|
||||
bond: DecCoin;
|
||||
stakeSaturation: number;
|
||||
profitMargin: number;
|
||||
nodeRewards: DecCoin;
|
||||
operatorRewards: DecCoin;
|
||||
delegators: number;
|
||||
status: MixnodeStatus;
|
||||
}
|
||||
|
||||
// TODO add relevant data
|
||||
export interface BondedGateway {
|
||||
identityKey: string;
|
||||
ip: string;
|
||||
bond: DecCoin;
|
||||
location?: string; // TODO not yet available, only available in Network Explorer API
|
||||
}
|
||||
|
||||
export type TokenPool = 'locked' | 'balance';
|
||||
|
||||
export type FeeOperation =
|
||||
| 'bondMixnode'
|
||||
| 'bondMixnodeWithVesting'
|
||||
| 'bondGateway'
|
||||
| 'bondGatewayWithVesting'
|
||||
| 'unbondMixnode'
|
||||
| 'unbondGateway'
|
||||
| 'updateMixnode'
|
||||
| 'bondMore'
|
||||
| 'compoundRewards'
|
||||
| 'redeemRewards';
|
||||
|
||||
export type TBondingContext = {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
bondedMixnode?: BondedMixnode | null;
|
||||
bondedGateway?: BondedGateway | null;
|
||||
refresh: () => Promise<void>;
|
||||
bondMixnode: (
|
||||
data: Omit<TBondMixNodeArgs, 'fee'>,
|
||||
tokenPool: TokenPool,
|
||||
) => Promise<TransactionExecuteResult | undefined>;
|
||||
bondGateway: (
|
||||
data: Omit<TBondGatewayArgs, 'fee'>,
|
||||
tokenPool: TokenPool,
|
||||
) => Promise<TransactionExecuteResult | undefined>;
|
||||
bondMore: (signature: string, additionalBond: DecCoin) => Promise<TransactionExecuteResult | undefined>;
|
||||
unbondMixnode: () => Promise<TransactionExecuteResult | undefined>;
|
||||
unbondGateway: () => Promise<TransactionExecuteResult | undefined>;
|
||||
redeemRewards: () => Promise<TransactionExecuteResult[] | undefined>;
|
||||
compoundRewards: () => Promise<TransactionExecuteResult[] | undefined>;
|
||||
updateMixnode: (pm: number) => Promise<TransactionExecuteResult | undefined>;
|
||||
fee?: FeeDetails;
|
||||
getFee: <T>(feeOperation: FeeOperation, args: T) => Promise<FeeDetails | undefined>;
|
||||
feeDetails?: FeeDetails;
|
||||
feeLoading: boolean;
|
||||
feeError?: string;
|
||||
resetFeeState: () => void;
|
||||
};
|
||||
|
||||
export const BondingContext = createContext<TBondingContext>({
|
||||
loading: true,
|
||||
feeLoading: false,
|
||||
refresh: async () => undefined,
|
||||
bondMixnode: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
bondGateway: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
unbondMixnode: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
unbondGateway: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
redeemRewards: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
compoundRewards: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
getFee(): Promise<FeeDetails> {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
resetFeeState(): void {},
|
||||
updateMixnode: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
bondMore(): Promise<TransactionExecuteResult | undefined> {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
});
|
||||
|
||||
export const BondingContextProvider = ({
|
||||
network,
|
||||
children,
|
||||
}: {
|
||||
network?: Network;
|
||||
children?: React.ReactNode;
|
||||
}): JSX.Element => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [bondedMixnode, setBondedMixnode] = useState<BondedMixnode | null>(null);
|
||||
const [bondedGateway, setBondedGateway] = useState<BondedGateway | null>(null);
|
||||
|
||||
const { userBalance } = useContext(AppContext);
|
||||
const { ownership, checkOwnership } = useCheckOwnership();
|
||||
const { fee, resetFeeState, feeError, isFeeLoading } = useGetFee();
|
||||
|
||||
const isVesting = Boolean(ownership.vestingPledge);
|
||||
|
||||
console.log(ownership);
|
||||
|
||||
useEffect(() => {
|
||||
if (feeError) {
|
||||
setError(`An error occurred while retrieving fee: ${feeError}`);
|
||||
}
|
||||
}, [feeError]);
|
||||
|
||||
const resetState = () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setBondedGateway(null);
|
||||
setBondedMixnode(null);
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
let data, status;
|
||||
setLoading(true);
|
||||
if (ownership.hasOwnership && ownership.nodeType === 'mixnode') {
|
||||
try {
|
||||
data = await getMixnodeBondDetails();
|
||||
status = data ? await getMixnodeStatus(data?.mix_node.identity_key) : undefined;
|
||||
} catch (e: any) {
|
||||
setError(`While fetching current bond state, an error occurred: ${e}`);
|
||||
}
|
||||
// TODO convert the returned data from the request to `BondedMixnode` type
|
||||
setBondedMixnode(bounded);
|
||||
}
|
||||
if (ownership.hasOwnership && ownership.nodeType === 'gateway') {
|
||||
try {
|
||||
data = await getGatewayBondDetails();
|
||||
status = data ? await getMixnodeStatus(data?.gateway.identity_key) : undefined;
|
||||
} catch (e: any) {
|
||||
setError(`While fetching current bond state, an error occurred: ${e}`);
|
||||
}
|
||||
// TODO convert the returned data from the request to `BondedGateway` type
|
||||
setBondedGateway(bounded);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [network, ownership]);
|
||||
|
||||
useEffect(() => {
|
||||
resetState();
|
||||
refresh();
|
||||
}, [network, ownership]);
|
||||
|
||||
const bondMixnode = async (data: Omit<TBondMixNodeArgs, 'fee'>, tokenPool: TokenPool) => {
|
||||
let tx: TransactionExecuteResult | undefined;
|
||||
const payload = {
|
||||
...data,
|
||||
fee: fee?.fee,
|
||||
};
|
||||
setLoading(true);
|
||||
try {
|
||||
if (tokenPool === 'balance') {
|
||||
tx = await bondMixNodeRequest(payload);
|
||||
await userBalance.fetchBalance();
|
||||
}
|
||||
if (tokenPool === 'locked') {
|
||||
tx = await vestingBondMixNode(payload);
|
||||
await userBalance.fetchTokenAllocation();
|
||||
}
|
||||
return tx;
|
||||
} catch (e: any) {
|
||||
setError(`an error occurred: ${e}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const bondGateway = async (data: Omit<TBondGatewayArgs, 'fee'>, tokenPool: TokenPool) => {
|
||||
let tx: TransactionExecuteResult | undefined;
|
||||
const payload = {
|
||||
...data,
|
||||
fee: fee?.fee,
|
||||
};
|
||||
setLoading(true);
|
||||
try {
|
||||
if (tokenPool === 'balance') {
|
||||
tx = await bondGatewayRequest(payload);
|
||||
await userBalance.fetchBalance();
|
||||
}
|
||||
if (tokenPool === 'locked') {
|
||||
tx = await vestingBondGateway(payload);
|
||||
await userBalance.fetchTokenAllocation();
|
||||
}
|
||||
return tx;
|
||||
} catch (e: any) {
|
||||
setError(`an error occurred: ${e}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const unbondMixnode = async () => {
|
||||
let tx;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isVesting) tx = await vestingUnbondMixnode(fee?.fee);
|
||||
if (!isVesting) tx = await unbondMixnodeRequest(fee?.fee);
|
||||
} catch (e: any) {
|
||||
setError(`an error occurred: ${e}`);
|
||||
} finally {
|
||||
await checkOwnership();
|
||||
setLoading(false);
|
||||
}
|
||||
return tx;
|
||||
};
|
||||
|
||||
const unbondGateway = async () => {
|
||||
let tx;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isVesting) tx = await vestingUnbondGateway(fee?.fee);
|
||||
if (!isVesting) tx = await unbondGatewayRequest(fee?.fee);
|
||||
} catch (e: any) {
|
||||
setError(`an error occurred: ${e}`);
|
||||
} finally {
|
||||
await checkOwnership();
|
||||
setLoading(false);
|
||||
}
|
||||
return tx;
|
||||
};
|
||||
|
||||
const updateMixnode = async (pm: number) => {
|
||||
let tx;
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO use estimated fee, need requests update
|
||||
if (isVesting) tx = await updateMixnodeRequest(pm);
|
||||
if (!isVesting) tx = await updateMixnodeVestingRequest(pm);
|
||||
} catch (e: any) {
|
||||
setError(`an error occurred: ${e}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return tx;
|
||||
};
|
||||
|
||||
const redeemRewards = async () => {
|
||||
let tx;
|
||||
setLoading(true);
|
||||
try {
|
||||
tx = await claimOperatorRewards(); // TODO use estimated fee, update `claimOperatorRewards`
|
||||
} catch (e: any) {
|
||||
setError(`an error occurred: ${e}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return tx;
|
||||
};
|
||||
|
||||
const compoundRewards = async () => {
|
||||
let tx;
|
||||
setLoading(true);
|
||||
try {
|
||||
tx = await compoundOperatorRewards(); // TODO use estimated fee, update `compoundOperatorRewards`
|
||||
} catch (e: any) {
|
||||
setError(`an error occurred: ${e}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return tx;
|
||||
};
|
||||
|
||||
const bondMore = async (_signature: string, _additionalBond: DecCoin) =>
|
||||
// TODO to implement
|
||||
undefined;
|
||||
|
||||
const feeOps = useMemo(
|
||||
() => ({
|
||||
bondMixnode: simulateBondMixnode,
|
||||
bondMixnodeWithVesting: simulateVestingBondMixnode,
|
||||
bondGateway: simulateBondGateway,
|
||||
bondGatewayWithVesting: simulateVestingBondGateway,
|
||||
unbondMixnode: isVesting ? simulateVestingUnbondMixnode : simulateUnbondMixnode,
|
||||
unbondGateway: isVesting ? simulateVestingUnbondGateway : simulateUnbondGateway,
|
||||
updateMixnode: isVesting ? simulateVestingUpdateMixnode : simulateUpdateMixnode,
|
||||
bondMore: () => undefined as unknown as Promise<FeeDetails>, // TODO fee request to implement
|
||||
compoundRewards: () => undefined as unknown as Promise<FeeDetails>, // TODO fee request to implement
|
||||
redeemRewards: () => undefined as unknown as Promise<FeeDetails>, // TODO fee request to implement
|
||||
}),
|
||||
[isVesting],
|
||||
);
|
||||
|
||||
const getFee = async (feeOperation: FeeOperation, args: any) => {
|
||||
let details;
|
||||
try {
|
||||
details = feeOps[feeOperation](args);
|
||||
} catch (e: any) {
|
||||
setError(`An error occurred while retrieving fee: ${e}`);
|
||||
}
|
||||
return details;
|
||||
};
|
||||
|
||||
const memoizedValue = useMemo(
|
||||
() => ({
|
||||
loading,
|
||||
error,
|
||||
bondMixnode,
|
||||
bondedMixnode,
|
||||
bondedGateway,
|
||||
bondGateway,
|
||||
unbondMixnode,
|
||||
unbondGateway,
|
||||
updateMixnode,
|
||||
refresh,
|
||||
redeemRewards,
|
||||
compoundRewards,
|
||||
feeLoading: isFeeLoading,
|
||||
feeError,
|
||||
getFee,
|
||||
fee,
|
||||
resetFeeState,
|
||||
bondMore,
|
||||
}),
|
||||
[loading, error, bondedMixnode, bondedGateway, isFeeLoading, feeError, fee, resetFeeState, isVesting],
|
||||
);
|
||||
|
||||
return <BondingContext.Provider value={memoizedValue}>{children}</BondingContext.Provider>;
|
||||
};
|
||||
|
||||
export const useBondingContext = () => useContext<TBondingContext>(BondingContext);
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './main';
|
||||
export * from './auth';
|
||||
export * from './accounts';
|
||||
export * from './bonding';
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { FeeDetails, DecCoin, TransactionExecuteResult } from '@nymproject/types';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Network } from 'src/types';
|
||||
import { BondedGateway, BondedMixnode, BondingContext, FeeOperation } from '../bonding';
|
||||
import { mockSleep } from './utils';
|
||||
|
||||
const SLEEP_MS = 1000;
|
||||
|
||||
const bondedMixnodeMock: BondedMixnode = {
|
||||
identityKey: '7mjM2fYbtN6kxMwp1TrmQ4VwPks3URR5pBgWPWhzT98F',
|
||||
ip: '112.43.234.56',
|
||||
stake: { denom: 'nym', amount: '1234' },
|
||||
bond: { denom: 'nym', amount: '1234' },
|
||||
stakeSaturation: 95,
|
||||
profitMargin: 15,
|
||||
nodeRewards: { denom: 'nym', amount: '1234' },
|
||||
operatorRewards: { denom: 'nym', amount: '1234' },
|
||||
delegators: 5423,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const bondedGatewayMock: BondedGateway = {
|
||||
identityKey: 'WayM2fYbtN6kxMwp1TrmQ4VwPks3URR5pBgWPWhzT98F',
|
||||
ip: '112.43.234.57',
|
||||
bond: { denom: 'nym', amount: '1234' },
|
||||
};
|
||||
|
||||
const TxResultMock: TransactionExecuteResult = {
|
||||
logs_json: '',
|
||||
data_json: '',
|
||||
transaction_hash: '55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354',
|
||||
gas_info: {
|
||||
gas_wanted: { gas_units: BigInt(1) },
|
||||
gas_used: { gas_units: BigInt(1) },
|
||||
},
|
||||
fee: { amount: '1', denom: 'nym' },
|
||||
};
|
||||
|
||||
const feeMock: FeeDetails = {
|
||||
amount: { denom: 'nym', amount: '1' },
|
||||
fee: { Auto: 1 },
|
||||
};
|
||||
|
||||
export const MockBondingContextProvider = ({
|
||||
network,
|
||||
children,
|
||||
}: {
|
||||
network?: Network;
|
||||
children?: React.ReactNode;
|
||||
}): JSX.Element => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [feeLoading, setFeeLoading] = useState(false);
|
||||
const [fee, setFee] = useState<FeeDetails | undefined>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [bondedData, setBondedData] = useState<BondedMixnode | BondedGateway | null>(null);
|
||||
const [bondedMixnode, setBondedMixnode] = useState<BondedMixnode | null>(null);
|
||||
const [bondedGateway, setBondedGateway] = useState<BondedGateway | null>(null);
|
||||
const [trigger, setTrigger] = useState<Date>(new Date());
|
||||
|
||||
const triggerStateUpdate = () => setTrigger(new Date());
|
||||
|
||||
const resetState = () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setBondedGateway(null);
|
||||
setBondedMixnode(null);
|
||||
};
|
||||
|
||||
// fake tauri request
|
||||
const fetchBondingData: () => Promise<BondedMixnode | BondedGateway | null> = async () => {
|
||||
await mockSleep(SLEEP_MS);
|
||||
return bondedData;
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const bounded = await fetchBondingData();
|
||||
if (bounded && 'stake' in bounded) {
|
||||
setBondedMixnode(bounded);
|
||||
}
|
||||
if (bounded && !('stake' in bounded)) {
|
||||
setBondedGateway(bounded);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [network]);
|
||||
|
||||
useEffect(() => {
|
||||
resetState();
|
||||
refresh();
|
||||
}, [network, bondedData]);
|
||||
|
||||
const bondMixnode = async (): Promise<TransactionExecuteResult> => {
|
||||
setLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
setBondedData(bondedMixnodeMock);
|
||||
setLoading(false);
|
||||
return TxResultMock;
|
||||
};
|
||||
|
||||
const bondGateway = async (): Promise<TransactionExecuteResult> => {
|
||||
setLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
setBondedData(bondedGatewayMock);
|
||||
setLoading(false);
|
||||
return TxResultMock;
|
||||
};
|
||||
|
||||
const unbondMixnode = async (): Promise<TransactionExecuteResult> => {
|
||||
setLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
setBondedData(null);
|
||||
setLoading(false);
|
||||
return TxResultMock;
|
||||
};
|
||||
|
||||
const unbondGateway = async (): Promise<TransactionExecuteResult> => {
|
||||
setLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
setBondedData(null);
|
||||
setLoading(false);
|
||||
return TxResultMock;
|
||||
};
|
||||
|
||||
const redeemRewards = async (): Promise<TransactionExecuteResult[] | undefined> => {
|
||||
setLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
triggerStateUpdate();
|
||||
setLoading(false);
|
||||
return [TxResultMock];
|
||||
};
|
||||
|
||||
const compoundRewards = async (): Promise<TransactionExecuteResult[] | undefined> => {
|
||||
setLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
triggerStateUpdate();
|
||||
setLoading(false);
|
||||
return [TxResultMock];
|
||||
};
|
||||
|
||||
const updateMixnode = async (): Promise<TransactionExecuteResult> => {
|
||||
setLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
triggerStateUpdate();
|
||||
setLoading(false);
|
||||
return TxResultMock;
|
||||
};
|
||||
|
||||
const bondMore = async (_signature: string, _additionalBond: DecCoin) => {
|
||||
setLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
triggerStateUpdate();
|
||||
setLoading(false);
|
||||
return TxResultMock;
|
||||
};
|
||||
|
||||
const getFee = async (_feeOperation: FeeOperation, _args: any) => {
|
||||
setFeeLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
setFeeLoading(false);
|
||||
setFee(feeMock);
|
||||
return feeMock;
|
||||
};
|
||||
|
||||
const resetFeeState = () => {};
|
||||
|
||||
const memoizedValue = useMemo(
|
||||
() => ({
|
||||
loading,
|
||||
error,
|
||||
bondMixnode,
|
||||
bondGateway,
|
||||
unbondMixnode,
|
||||
unbondGateway,
|
||||
refresh,
|
||||
redeemRewards,
|
||||
compoundRewards,
|
||||
fee,
|
||||
feeLoading,
|
||||
getFee,
|
||||
resetFeeState,
|
||||
updateMixnode,
|
||||
bondMore,
|
||||
}),
|
||||
[loading, error, bondedMixnode, bondedGateway, trigger, fee],
|
||||
);
|
||||
|
||||
return <BondingContext.Provider value={memoizedValue}>{children}</BondingContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { BondingPage } from './index';
|
||||
import { MockBondingContextProvider } from '../../context/mocks/bonding';
|
||||
|
||||
export default {
|
||||
title: 'Bonding/Flows/Mock',
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<MockBondingContextProvider>
|
||||
<BondingPage />
|
||||
</MockBondingContextProvider>
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Box, Divider, Stack, Typography } from '@mui/material';
|
||||
import { AmountData, NodeType } from '../types';
|
||||
import { AppContext } from '../../../context';
|
||||
import amountSchema from './amountSchema';
|
||||
import { TokenPoolSelector } from '../../../components';
|
||||
import { TextFieldInput, CurrencyInput } from '../components';
|
||||
import { checkHasEnoughFunds, checkHasEnoughLockedTokens } from '../../../utils';
|
||||
import { SimpleModal } from '../../../components/Modals/SimpleModal';
|
||||
|
||||
export interface Props {
|
||||
nodeType: NodeType;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
onSubmit: (data: AmountData) => Promise<void>;
|
||||
}
|
||||
|
||||
const AmountModal = ({ open, onClose, onSubmit, nodeType }: Props) => {
|
||||
const {
|
||||
control,
|
||||
setValue,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<AmountData>({
|
||||
resolver: yupResolver(amountSchema),
|
||||
defaultValues: {
|
||||
tokenPool: 'balance',
|
||||
profitMargin: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const { userBalance, denom } = useContext(AppContext);
|
||||
|
||||
const onSubmitForm = async (data: AmountData) => {
|
||||
if (data.tokenPool === 'balance' && !(await checkHasEnoughFunds(data.amount.amount || ''))) {
|
||||
return setError('amount.amount', { message: 'Not enough funds in wallet' });
|
||||
}
|
||||
|
||||
if (data.tokenPool === 'locked' && !(await checkHasEnoughLockedTokens(data.amount.amount || ''))) {
|
||||
return setError('amount.amount', { message: 'Not enough locked tokens' });
|
||||
}
|
||||
return onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOk={handleSubmit(onSubmitForm)}
|
||||
header="Bond"
|
||||
subHeader="Step 2/2"
|
||||
okLabel="Next"
|
||||
>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<form>
|
||||
{nodeType === 'mixnode' && (
|
||||
<TextFieldInput
|
||||
name="profitMargin"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Profit Margin"
|
||||
placeholder="Profit Margin"
|
||||
error={Boolean(errors.profitMargin)}
|
||||
helperText={errors.profitMargin ? errors.profitMargin.message : 'Default is 10%'}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
)}
|
||||
<Stack direction="row" spacing={2}>
|
||||
{userBalance.originalVesting && (
|
||||
<TokenPoolSelector onSelect={(pool) => setValue('tokenPool', pool)} disabled={false} />
|
||||
)}
|
||||
<CurrencyInput
|
||||
control={control}
|
||||
required
|
||||
fullWidth
|
||||
label="Amount"
|
||||
name="amount"
|
||||
currencyDenom={denom}
|
||||
errorMessage={errors.amount?.amount?.message}
|
||||
/>
|
||||
</Stack>
|
||||
</form>
|
||||
<Stack direction="row" justifyContent="space-between" mt={3}>
|
||||
<Typography fontWeight={600}>Account balance</Typography>
|
||||
<Typography fontWeight={600} textTransform="uppercase">
|
||||
{userBalance.balance?.printable_balance || 0}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography fontWeight={400}>Est. fee for this transaction will be calculated in the next page</Typography>
|
||||
</Box>
|
||||
</SimpleModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AmountModal;
|
||||
@@ -0,0 +1,249 @@
|
||||
import React, { useContext, useEffect, useReducer } from 'react';
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
import { Link } from '@nymproject/react/link/Link';
|
||||
import { TransactionExecuteResult } from '@nymproject/types';
|
||||
import { ErrorOutline } from '@mui/icons-material';
|
||||
import { ConfirmationModal, NymCard } from '../../../components';
|
||||
import NodeIdentityModal from './NodeIdentityModal';
|
||||
import {
|
||||
ACTIONTYPE,
|
||||
BondState,
|
||||
BondStatus,
|
||||
FormStep,
|
||||
GatewayAmount,
|
||||
GatewayData,
|
||||
MixnodeAmount,
|
||||
MixnodeData,
|
||||
NodeData,
|
||||
} from '../types';
|
||||
import AmountModal from './AmountModal';
|
||||
import { AppContext, urls, useBondingContext } from '../../../context';
|
||||
import SummaryModal from './SummaryModal';
|
||||
|
||||
const initialState: BondState = {
|
||||
showModal: false,
|
||||
formStep: 1,
|
||||
bondStatus: 'init',
|
||||
};
|
||||
|
||||
function reducer(state: BondState, action: ACTIONTYPE) {
|
||||
let step;
|
||||
switch (action.type) {
|
||||
case 'change_bond_type':
|
||||
return { ...state, type: action.payload };
|
||||
case 'set_node_data':
|
||||
return { ...state, nodeData: action.payload };
|
||||
case 'set_amount_data':
|
||||
return { ...state, amountData: action.payload };
|
||||
case 'set_step':
|
||||
return { ...state, formStep: action.payload };
|
||||
case 'set_tx':
|
||||
return { ...state, tx: action.payload };
|
||||
case 'set_bond_status':
|
||||
return { ...state, bondStatus: action.payload };
|
||||
case 'set_error':
|
||||
return { ...state, error: action.payload, bondStatus: 'error' as BondStatus };
|
||||
case 'next_step':
|
||||
step = state.formStep + 1;
|
||||
return { ...state, formStep: step <= 4 ? (step as FormStep) : 4 };
|
||||
case 'prev_step':
|
||||
step = state.formStep - 1;
|
||||
return { ...state, formStep: step >= 1 ? (step as FormStep) : 1 };
|
||||
case 'show_modal':
|
||||
return { ...state, showModal: true };
|
||||
case 'close_modal':
|
||||
return { ...state, showModal: false };
|
||||
case 'reset':
|
||||
return initialState;
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
const BondingCard = () => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { formStep, showModal } = state;
|
||||
|
||||
const { clientDetails, network } = useContext(AppContext);
|
||||
const { error, bondMixnode: bondMixnodeRequest, bondGateway: bondGatewayRequest } = useBondingContext();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'reset' });
|
||||
}, [clientDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
dispatch({ type: 'set_error', payload: error });
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const bondMixnode = async () => {
|
||||
const { signature, identityKey, sphinxKey, host, version, mixPort, verlocPort, httpApiPort } =
|
||||
state.nodeData as NodeData<MixnodeData>;
|
||||
const { profitMargin, amount, tokenPool } = state.amountData as MixnodeAmount;
|
||||
const payload = {
|
||||
ownerSignature: signature,
|
||||
mixnode: {
|
||||
identity_key: identityKey,
|
||||
sphinx_key: sphinxKey,
|
||||
host,
|
||||
version,
|
||||
mix_port: mixPort,
|
||||
profit_margin_percent: profitMargin,
|
||||
verloc_port: verlocPort,
|
||||
http_api_port: httpApiPort,
|
||||
},
|
||||
pledge: amount,
|
||||
};
|
||||
if (tokenPool !== 'locked' && tokenPool !== 'balance') {
|
||||
throw new Error(`token pool [${tokenPool}] not supported`);
|
||||
}
|
||||
const tx = await bondMixnodeRequest(payload, tokenPool);
|
||||
if (tx) {
|
||||
dispatch({ type: 'set_bond_status', payload: 'success' });
|
||||
} else {
|
||||
dispatch({ type: 'set_bond_status', payload: 'error' });
|
||||
}
|
||||
return tx;
|
||||
};
|
||||
|
||||
const bondGateway = async () => {
|
||||
const { signature, identityKey, sphinxKey, host, version, location, mixPort, clientsPort } =
|
||||
state.nodeData as NodeData<GatewayData>;
|
||||
const { amount, tokenPool } = state.amountData as GatewayAmount;
|
||||
const payload = {
|
||||
ownerSignature: signature,
|
||||
gateway: {
|
||||
identity_key: identityKey,
|
||||
sphinx_key: sphinxKey,
|
||||
host,
|
||||
version,
|
||||
mix_port: mixPort,
|
||||
location,
|
||||
clients_port: clientsPort,
|
||||
},
|
||||
pledge: amount,
|
||||
};
|
||||
if (tokenPool !== 'locked' && tokenPool !== 'balance') {
|
||||
throw new Error(`token pool [${tokenPool}] not supported`);
|
||||
}
|
||||
const tx = await bondGatewayRequest(payload, tokenPool);
|
||||
if (tx) {
|
||||
dispatch({ type: 'set_bond_status', payload: 'success' });
|
||||
} else {
|
||||
dispatch({ type: 'set_bond_status', payload: 'error' });
|
||||
}
|
||||
return tx;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { nodeData } = state;
|
||||
let tx: TransactionExecuteResult | undefined;
|
||||
// TODO show a special UI for loading state
|
||||
dispatch({ type: 'set_bond_status', payload: 'loading' });
|
||||
if ((nodeData as NodeData).nodeType === 'mixnode') {
|
||||
tx = await bondMixnode();
|
||||
} else {
|
||||
tx = await bondGateway();
|
||||
}
|
||||
dispatch({ type: 'set_tx', payload: tx });
|
||||
if (state.bondStatus === 'success') {
|
||||
dispatch({ type: 'next_step' });
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
dispatch({ type: 'close_modal' });
|
||||
dispatch({ type: 'reset' });
|
||||
};
|
||||
|
||||
return (
|
||||
<NymCard title="Bonding">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pt: 0,
|
||||
}}
|
||||
>
|
||||
<Typography>Bond a node or a gateway</Typography>
|
||||
<Button
|
||||
disabled={false}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="button"
|
||||
disableElevation
|
||||
onClick={() => dispatch({ type: 'show_modal' })}
|
||||
sx={{ py: 1.5, px: 3 }}
|
||||
>
|
||||
Bond
|
||||
</Button>
|
||||
</Box>
|
||||
{formStep === 1 && showModal && (
|
||||
<NodeIdentityModal
|
||||
open={formStep === 1 && showModal}
|
||||
onClose={() => dispatch({ type: 'reset' })}
|
||||
onSubmit={async (data) => {
|
||||
dispatch({ type: 'set_node_data', payload: data });
|
||||
dispatch({ type: 'next_step' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{formStep === 2 && showModal && (
|
||||
<AmountModal
|
||||
open={formStep === 2 && showModal}
|
||||
onClose={() => dispatch({ type: 'reset' })}
|
||||
onSubmit={async (data) => {
|
||||
dispatch({ type: 'set_amount_data', payload: data });
|
||||
dispatch({ type: 'next_step' });
|
||||
}}
|
||||
nodeType={state.nodeData?.nodeType || 'mixnode'}
|
||||
/>
|
||||
)}
|
||||
{formStep === 3 && showModal && (
|
||||
<SummaryModal
|
||||
open={formStep === 3 && showModal}
|
||||
onClose={() => dispatch({ type: 'reset' })}
|
||||
onCancel={() => dispatch({ type: 'prev_step' })}
|
||||
onSubmit={onSubmit}
|
||||
node={state.nodeData as NodeData}
|
||||
amount={state.amountData as MixnodeAmount | GatewayAmount}
|
||||
onError={(msg: string) => {
|
||||
dispatch({ type: 'set_error', payload: msg });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{state.bondStatus === 'success' && formStep === 4 && showModal && (
|
||||
<ConfirmationModal
|
||||
open={formStep === 4 && showModal}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onConfirm}
|
||||
title="Bonding successful"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<Link href={`${urls(network).blockExplorer}/transaction/${state.tx?.transaction_hash}`} noIcon>
|
||||
View on blockchain
|
||||
</Link>
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
{state.bondStatus === 'error' && (
|
||||
<ConfirmationModal
|
||||
open={showModal}
|
||||
onClose={() => dispatch({ type: 'reset' })}
|
||||
onConfirm={() => dispatch({ type: 'reset' })}
|
||||
title="Unbonding failed"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography variant="caption">Error: {state.error}</Typography>
|
||||
<ErrorOutline color="error" />
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</NymCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default BondingCard;
|
||||
@@ -0,0 +1,221 @@
|
||||
import React from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { Stack } from '@mui/material';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FieldErrors } from 'react-hook-form/dist/types/errors';
|
||||
import { GatewayData, MixnodeData, NodeData, NodeType } from '../types';
|
||||
import { RadioInput, TextFieldInput, CheckboxInput } from '../components';
|
||||
import nodeSchema from './nodeSchema';
|
||||
import { SimpleModal } from '../../../components/Modals/SimpleModal';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
onSubmit: (data: NodeData) => Promise<void>;
|
||||
}
|
||||
|
||||
const radioOptions: { label: string; value: NodeType }[] = [
|
||||
{
|
||||
label: 'Mixnode',
|
||||
value: 'mixnode',
|
||||
},
|
||||
{
|
||||
label: 'Gateway',
|
||||
value: 'gateway',
|
||||
},
|
||||
];
|
||||
|
||||
const NodeIdentityModal = ({ open, onClose, onSubmit }: Props) => {
|
||||
const {
|
||||
control,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<NodeData>({
|
||||
defaultValues: {
|
||||
nodeType: 'mixnode',
|
||||
advancedOpt: false,
|
||||
mixPort: 1789,
|
||||
verlocPort: 1790,
|
||||
httpApiPort: 8000,
|
||||
clientsPort: 9000,
|
||||
},
|
||||
resolver: yupResolver(nodeSchema),
|
||||
});
|
||||
|
||||
const nodeType = useWatch({ name: 'nodeType', control });
|
||||
const advancedOpt = useWatch({ name: 'advancedOpt', control });
|
||||
|
||||
const onSubmitForm = (data: NodeData) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOk={handleSubmit(onSubmitForm)}
|
||||
header="Bond"
|
||||
subHeader="Step 1/2"
|
||||
okLabel="Next"
|
||||
>
|
||||
<form>
|
||||
<RadioInput
|
||||
name="nodeType"
|
||||
label="Select node type"
|
||||
options={radioOptions}
|
||||
control={control}
|
||||
defaultValue={getValues('nodeType')}
|
||||
muiRadioGroupProps={{ row: true }}
|
||||
/>
|
||||
<TextFieldInput
|
||||
name="identityKey"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Identity Key"
|
||||
placeholder="Identity Key"
|
||||
error={Boolean(errors.identityKey)}
|
||||
helperText={errors.identityKey?.message}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mb: 2.5, mt: 1 }}
|
||||
/>
|
||||
<TextFieldInput
|
||||
name="sphinxKey"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Sphinx Key"
|
||||
placeholder="Sphinx Key"
|
||||
error={Boolean(errors.sphinxKey)}
|
||||
helperText={errors.sphinxKey?.message}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
<TextFieldInput
|
||||
name="signature"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Signature"
|
||||
placeholder="Signature"
|
||||
error={Boolean(errors.signature)}
|
||||
helperText={errors.signature?.message}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
{nodeType === 'gateway' && (
|
||||
<TextFieldInput
|
||||
name="location"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Location"
|
||||
placeholder="Location"
|
||||
error={Boolean((errors as FieldErrors<NodeData<GatewayData>>).location)}
|
||||
helperText={(errors as FieldErrors<NodeData<GatewayData>>).location?.message}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
)}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextFieldInput
|
||||
name="host"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Host"
|
||||
placeholder="Host"
|
||||
error={Boolean(errors.host)}
|
||||
helperText={errors.host?.message}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
<TextFieldInput
|
||||
name="version"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Version"
|
||||
placeholder="Version"
|
||||
error={Boolean(errors.version)}
|
||||
helperText={errors.version?.message}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
</Stack>
|
||||
<CheckboxInput
|
||||
name="advancedOpt"
|
||||
label="Use advanced options"
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
{advancedOpt && (
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<TextFieldInput
|
||||
name="mixPort"
|
||||
control={control}
|
||||
label="Mix Port"
|
||||
placeholder="Mix Port"
|
||||
error={Boolean(errors.mixPort)}
|
||||
helperText={errors.mixPort?.message && 'A valid port value is required'}
|
||||
required
|
||||
registerOptions={{ valueAsNumber: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
{nodeType === 'mixnode' ? (
|
||||
<>
|
||||
<TextFieldInput
|
||||
name="verlocPort"
|
||||
control={control}
|
||||
label="Verloc Port"
|
||||
placeholder="Verloc Port"
|
||||
error={Boolean((errors as FieldErrors<NodeData<MixnodeData>>).verlocPort)}
|
||||
helperText={
|
||||
(errors as FieldErrors<NodeData<MixnodeData>>).verlocPort?.message &&
|
||||
'A valid port value is required'
|
||||
}
|
||||
required
|
||||
registerOptions={{ valueAsNumber: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
<TextFieldInput
|
||||
name="httpApiPort"
|
||||
control={control}
|
||||
label="HTTP API Port"
|
||||
placeholder="HTTP API Port"
|
||||
error={Boolean((errors as FieldErrors<NodeData<MixnodeData>>).httpApiPort)}
|
||||
helperText={
|
||||
(errors as FieldErrors<NodeData<MixnodeData>>).httpApiPort?.message &&
|
||||
'A valid port value is required'
|
||||
}
|
||||
required
|
||||
registerOptions={{ valueAsNumber: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextFieldInput
|
||||
name="clientsPort"
|
||||
control={control}
|
||||
label="client WS API Port"
|
||||
placeholder="client WS API Port"
|
||||
error={Boolean((errors as FieldErrors<NodeData<GatewayData>>).clientsPort)}
|
||||
helperText={
|
||||
(errors as FieldErrors<NodeData<GatewayData>>).clientsPort?.message &&
|
||||
'A valid port value is required'
|
||||
}
|
||||
required
|
||||
registerOptions={{ valueAsNumber: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</form>
|
||||
</SimpleModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeIdentityModal;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import { GatewayAmount, GatewayData, MixnodeAmount, MixnodeData, NodeData } from '../types';
|
||||
import { SimpleModal } from '../../../components/Modals/SimpleModal';
|
||||
import { useBondingContext } from '../../../context';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
onError: (message: string) => void;
|
||||
node: NodeData;
|
||||
amount: MixnodeAmount | GatewayAmount;
|
||||
}
|
||||
|
||||
const SummaryModal = ({ open, onClose, onSubmit, node, amount, onCancel, onError }: Props) => {
|
||||
const { fee, getFee, resetFeeState, feeError, feeLoading } = useBondingContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (feeError) onError(feeError);
|
||||
}, [feeError]);
|
||||
|
||||
const fetchFee = async () => {
|
||||
const { signature, host, version, mixPort, identityKey, sphinxKey } = node;
|
||||
try {
|
||||
if (node.nodeType === 'mixnode') {
|
||||
await getFee(amount.tokenPool === 'locked' ? 'bondMixnodeWithVesting' : 'bondMixnode', {
|
||||
ownerSignature: signature,
|
||||
mixnode: {
|
||||
identity_key: identityKey,
|
||||
sphinx_key: sphinxKey,
|
||||
host,
|
||||
version,
|
||||
mix_port: mixPort,
|
||||
profit_margin_percent: (amount as MixnodeAmount).profitMargin,
|
||||
verloc_port: (node as NodeData<MixnodeData>).verlocPort,
|
||||
http_api_port: (node as NodeData<MixnodeData>).httpApiPort,
|
||||
},
|
||||
pledge: amount.amount,
|
||||
});
|
||||
} else {
|
||||
await getFee(amount.tokenPool === 'locked' ? 'bondGatewayWithVesting' : 'bondGateway', {
|
||||
ownerSignature: signature,
|
||||
gateway: {
|
||||
identity_key: identityKey,
|
||||
sphinx_key: sphinxKey,
|
||||
host,
|
||||
version,
|
||||
mix_port: mixPort,
|
||||
location: (node as NodeData<GatewayData>).location,
|
||||
clients_port: (node as NodeData<GatewayData>).clientsPort,
|
||||
},
|
||||
pledge: amount.amount,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
onError(e as string);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFee();
|
||||
}, [node, amount]);
|
||||
|
||||
const onConfirm = async () => onSubmit();
|
||||
|
||||
return (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
resetFeeState();
|
||||
onClose();
|
||||
}}
|
||||
onBack={() => {
|
||||
resetFeeState();
|
||||
onCancel();
|
||||
}}
|
||||
onOk={onConfirm}
|
||||
header="Bond details"
|
||||
okLabel="Confirm"
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography>Identity Key</Typography>
|
||||
<Typography>{node.identityKey}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography>Amount</Typography>
|
||||
<Typography>{`${amount.amount.amount} ${amount.amount.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography>Fee for this operation</Typography>
|
||||
{feeLoading ? (
|
||||
<Typography>loading</Typography>
|
||||
) : (
|
||||
<Typography>{fee ? `${fee.amount?.amount} ${fee.amount?.denom}` : ''}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</SimpleModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SummaryModal;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { number, object, string } from 'yup';
|
||||
import { validateAmount } from '../../../utils';
|
||||
|
||||
const amountSchema = object().shape({
|
||||
amount: object().shape({
|
||||
amount: string()
|
||||
.required('An amount is required')
|
||||
.test('valid-amount', 'Pledge error', async function isValidAmount(this, value) {
|
||||
const isValid = await validateAmount(value || '', '100');
|
||||
if (!isValid) {
|
||||
return this.createError({ message: 'A valid amount is required (min 100)' });
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
}),
|
||||
profitMargin: number().required('Profit Percentage is required').min(0).max(100),
|
||||
});
|
||||
|
||||
export default amountSchema;
|
||||
@@ -0,0 +1,3 @@
|
||||
import BondingCard from './BondingCard';
|
||||
|
||||
export default BondingCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { boolean, lazy, mixed, number, object, string } from 'yup';
|
||||
import { isValidHostname, validateKey, validateLocation, validateRawPort, validateVersion } from '../../../utils';
|
||||
import { NodeType } from '../types';
|
||||
|
||||
const nodeSchema = object().shape({
|
||||
nodeType: string().required().oneOf(['mixnode', 'gateway']),
|
||||
identityKey: string()
|
||||
.required('An indentity key is required')
|
||||
.test('valid-id-key', 'A valid identity key is required', (value) => validateKey(value || '', 32)),
|
||||
|
||||
sphinxKey: string()
|
||||
.required('A sphinx key is required')
|
||||
.test('valid-sphinx-key', 'A valid sphinx key is required', (value) => validateKey(value || '', 32)),
|
||||
|
||||
signature: string()
|
||||
.required('Signature is required')
|
||||
.test('valid-signature', 'A valid signature is required', (value) => validateKey(value || '', 64)),
|
||||
|
||||
host: string()
|
||||
.required('A host is required')
|
||||
.test('valid-host', 'A valid host is required', (value) => (value ? isValidHostname(value) : false)),
|
||||
|
||||
version: string()
|
||||
.required('A version is required')
|
||||
.test('valid-version', 'A valid version is required', (value) => (value ? validateVersion(value) : false)),
|
||||
|
||||
advancedOpt: boolean().required(),
|
||||
|
||||
location: lazy((value) => {
|
||||
if (value) {
|
||||
return string()
|
||||
.required('A location is required')
|
||||
.test('valid-location', 'A valid version is required', (locationValueTest) =>
|
||||
locationValueTest ? validateLocation(locationValueTest) : false,
|
||||
);
|
||||
}
|
||||
return mixed().notRequired();
|
||||
}),
|
||||
|
||||
mixPort: number()
|
||||
.required('A mixport is required')
|
||||
.test('valid-mixport', 'A valid mixport is required', (value) => (value ? validateRawPort(value) : false)),
|
||||
|
||||
verlocPort: number()
|
||||
.required('A verloc port is required')
|
||||
.test('valid-verloc', 'A valid verloc port is required', (value) => (value ? validateRawPort(value) : false)),
|
||||
|
||||
httpApiPort: number()
|
||||
.required('A http-api port is required')
|
||||
.test('valid-http', 'A valid http-api port is required', (value) => (value ? validateRawPort(value) : false)),
|
||||
|
||||
clientsPort: number()
|
||||
.required('A clients port is required')
|
||||
.test('valid-clients', 'A valid clients port is required', (value) => (value ? validateRawPort(value) : false)),
|
||||
});
|
||||
|
||||
export default nodeSchema;
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import { Control, useController } from 'react-hook-form';
|
||||
import { Checkbox, CheckboxProps, FormControlLabel, FormControlLabelProps, FormGroup, SxProps } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
control: Control<any>;
|
||||
defaultValue: boolean;
|
||||
muiCheckboxProps?: CheckboxProps;
|
||||
muiFormControlLabelProps?: FormControlLabelProps;
|
||||
sx?: SxProps;
|
||||
}
|
||||
|
||||
const CheckboxInput = ({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
label,
|
||||
muiCheckboxProps,
|
||||
muiFormControlLabelProps,
|
||||
sx,
|
||||
}: Props) => {
|
||||
const {
|
||||
field: { onChange, onBlur, value, ref },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
});
|
||||
return (
|
||||
<FormGroup sx={sx}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
checked={value}
|
||||
inputRef={ref}
|
||||
name={name}
|
||||
{...muiCheckboxProps}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
{...muiFormControlLabelProps}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckboxInput;
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { Control, useController } from 'react-hook-form';
|
||||
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
control: Control<any>;
|
||||
required?: boolean;
|
||||
fullWidth?: boolean;
|
||||
errorMessage?: string;
|
||||
currencyDenom?: string;
|
||||
}
|
||||
|
||||
const CurrencyInput = ({ name, label, control, errorMessage, currencyDenom, required, fullWidth }: Props) => {
|
||||
const {
|
||||
field: { onChange },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
});
|
||||
|
||||
return (
|
||||
<CurrencyFormField
|
||||
showCoinMark
|
||||
required={required}
|
||||
fullWidth={fullWidth}
|
||||
label={label}
|
||||
onChanged={onChange}
|
||||
denom={currencyDenom}
|
||||
validationError={errorMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrencyInput;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import { ActionsMenu, ActionsMenuItem } from 'src/components/ActionsMenu';
|
||||
import { Bond as BondIcon, Unbond as UnbondIcon } from '../../../svg-icons';
|
||||
import { GatewayFlow } from '../gateway/types';
|
||||
import { MixnodeFlow } from '../mixnode/types';
|
||||
|
||||
interface Item {
|
||||
label: string;
|
||||
flow: MixnodeFlow | GatewayFlow;
|
||||
icon: React.ReactNode;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const NodeMenu = ({ onFlowChange }: { onFlowChange: (flow: MixnodeFlow) => void }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleOpen = () => setIsOpen(true);
|
||||
const handleClose = () => setIsOpen(false);
|
||||
|
||||
const handleActionClick = (flow: MixnodeFlow | GatewayFlow) => {
|
||||
onFlowChange(flow);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionsMenu open={isOpen} onOpen={handleOpen} onClose={handleClose}>
|
||||
<ActionsMenuItem
|
||||
title="Bond more"
|
||||
Icon={<BondIcon fontSize="inherit" />}
|
||||
onClick={() => handleActionClick('bondMore')}
|
||||
/>
|
||||
<ActionsMenuItem
|
||||
title="Unbond"
|
||||
Icon={<UnbondIcon fontSize="inherit" />}
|
||||
onClick={() => handleActionClick('unbond')}
|
||||
/>
|
||||
<ActionsMenuItem
|
||||
title="Compound rewards"
|
||||
Icon={<Typography sx={{ pl: 1 }}>C</Typography>}
|
||||
description="Add operator rewards to bond"
|
||||
onClick={() => handleActionClick('compound')}
|
||||
/>
|
||||
<ActionsMenuItem
|
||||
title="Redeem rewards"
|
||||
Icon={<Typography sx={{ pl: 1 }}>R</Typography>}
|
||||
description="Add your rewards to bonding pool"
|
||||
onClick={() => handleActionClick('redeem')}
|
||||
/>
|
||||
</ActionsMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeMenu;
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Stack,
|
||||
SxProps,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell as MUITableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { InfoOutlined } from '@mui/icons-material';
|
||||
|
||||
export interface TableCell {
|
||||
children: React.ReactNode;
|
||||
color?: string;
|
||||
align?: 'center' | 'inherit' | 'justify' | 'left' | 'right';
|
||||
size?: 'small' | 'medium';
|
||||
sx?: SxProps;
|
||||
}
|
||||
|
||||
export type TableHeader = TableCell & { tooltip?: React.ReactNode };
|
||||
|
||||
const CellHeader = ({ children, tooltip, sx, size, align, color }: TableHeader) => (
|
||||
<MUITableCell sx={{ py: 1.2, color, ...sx }} size={size} align={align}>
|
||||
{tooltip ? (
|
||||
<Tooltip title={tooltip} arrow placement="top-start">
|
||||
<Stack direction="row" alignItems="center" fontSize="0.8rem">
|
||||
<InfoOutlined fontSize="inherit" sx={{ mr: 0.5 }} />
|
||||
<Typography>{children}</Typography>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography>{children}</Typography>
|
||||
)}
|
||||
</MUITableCell>
|
||||
);
|
||||
|
||||
export type Header = Omit<TableHeader, 'children'> & { header?: React.ReactNode; id: string };
|
||||
export type Cell = Omit<TableCell, 'children'> & { cell: React.ReactNode; id?: string };
|
||||
|
||||
export interface TableProps {
|
||||
headers: Header[];
|
||||
cells: Cell[];
|
||||
}
|
||||
|
||||
const NodeTable = ({ headers, cells }: TableProps) => (
|
||||
<TableContainer component={Box}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="node-table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headers.map(({ header, id, tooltip, sx }) => (
|
||||
<CellHeader tooltip={tooltip} key={id} sx={sx}>
|
||||
{header}
|
||||
</CellHeader>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow key="node-data">
|
||||
{cells.map(({ cell, id, align, size, color, sx }) => (
|
||||
<MUITableCell component="th" scope="row" sx={{ py: 1, color, ...sx }} align={align} size={size} key={id}>
|
||||
{cell}
|
||||
</MUITableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
export default NodeTable;
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { Control, useController } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormLabel,
|
||||
FormLabelProps,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
RadioGroupProps,
|
||||
RadioProps,
|
||||
} from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
control: Control<any>;
|
||||
options: { label: string; value: any }[];
|
||||
defaultValue: any;
|
||||
muiRadioGroupProps?: RadioGroupProps;
|
||||
muiRadioProps?: RadioProps;
|
||||
muiFormLabelProps?: FormLabelProps;
|
||||
}
|
||||
|
||||
const RadioInput = ({
|
||||
label,
|
||||
control,
|
||||
options,
|
||||
defaultValue,
|
||||
name,
|
||||
muiRadioGroupProps,
|
||||
muiRadioProps,
|
||||
muiFormLabelProps,
|
||||
}: Props) => {
|
||||
const {
|
||||
field: { onChange, value, ref },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
rules: { required: true },
|
||||
defaultValue,
|
||||
});
|
||||
return (
|
||||
<FormControl ref={ref}>
|
||||
<FormLabel
|
||||
id={`radio-group-label-${name}`}
|
||||
sx={{
|
||||
color: 'text.main',
|
||||
}}
|
||||
{...muiFormLabelProps}
|
||||
>
|
||||
{label}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-labelledby={`radio-group-label-${name}`}
|
||||
name={name}
|
||||
sx={{
|
||||
color: 'text.main',
|
||||
}}
|
||||
{...muiRadioGroupProps}
|
||||
>
|
||||
{options.map(({ value: v, label: l }) => (
|
||||
<FormControlLabel value={v} control={<Radio color="default" {...muiRadioProps} />} label={l} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadioInput;
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from 'react';
|
||||
import { Control, useController } from 'react-hook-form';
|
||||
import { SxProps, TextField, TextFieldProps } from '@mui/material';
|
||||
import { RegisterOptions } from 'react-hook-form/dist/types/validator';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
control: Control<any>;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
error?: boolean;
|
||||
muiTextFieldProps?: TextFieldProps;
|
||||
helperText?: string;
|
||||
sx?: SxProps;
|
||||
registerOptions?: RegisterOptions;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TextFieldInput = ({
|
||||
name,
|
||||
label,
|
||||
control,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
muiTextFieldProps,
|
||||
required,
|
||||
error,
|
||||
helperText,
|
||||
registerOptions,
|
||||
sx,
|
||||
disabled,
|
||||
}: Props) => {
|
||||
const {
|
||||
field: { onChange, onBlur, value, ref },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules: registerOptions,
|
||||
});
|
||||
return (
|
||||
<TextField
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
value={value}
|
||||
name={name}
|
||||
id={name}
|
||||
label={label}
|
||||
variant="outlined"
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
inputRef={ref}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
{...muiTextFieldProps}
|
||||
sx={sx}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextFieldInput;
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as NodeTable } from './NodeTable';
|
||||
export { default as CheckboxInput } from './CheckboxInput';
|
||||
export { default as RadioInput } from './RadioInput';
|
||||
export { default as TextFieldInput } from './TextFieldInput';
|
||||
export { default as CurrencyInput } from './CurrencyInput';
|
||||
export { default as NodeMenu } from './NodeMenu';
|
||||
|
||||
export * from './NodeTable';
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { NymCard } from 'src/components';
|
||||
import { IdentityKey } from 'src/components/IdentityKey';
|
||||
import { BondedGateway } from '../../../context';
|
||||
import { Cell, Header, NodeMenu, NodeTable } from '../components';
|
||||
import Unbond from '../unbond';
|
||||
import { GatewayFlow } from './types';
|
||||
|
||||
const headers: Header[] = [
|
||||
{
|
||||
header: 'IP',
|
||||
id: 'ip-header',
|
||||
sx: { pl: 0, width: 100 },
|
||||
},
|
||||
{
|
||||
header: 'Bond',
|
||||
id: 'bond-header',
|
||||
},
|
||||
{
|
||||
id: 'menu-button',
|
||||
size: 'small',
|
||||
sx: { width: 34, maxWidth: 34 },
|
||||
},
|
||||
];
|
||||
|
||||
const GatewayCard = ({ gateway }: { gateway: BondedGateway }) => {
|
||||
const { ip, bond } = gateway;
|
||||
const [flow, setFlow] = useState<GatewayFlow>(null);
|
||||
|
||||
const cells: Cell[] = [
|
||||
{
|
||||
cell: ip,
|
||||
id: 'ip-cell',
|
||||
sx: { pl: 0 },
|
||||
},
|
||||
{
|
||||
cell: `${bond.amount} ${bond.denom}`,
|
||||
id: 'bond-cell',
|
||||
},
|
||||
{
|
||||
cell: <NodeMenu onFlowChange={(newFlow) => setFlow(newFlow as GatewayFlow)} />,
|
||||
id: 'menu-button-cell',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NymCard
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Typography variant="h5">Valhalla gateway</Typography>
|
||||
<IdentityKey identityKey={gateway.identityKey} />
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<NodeTable headers={headers} cells={cells} />
|
||||
<Unbond node={gateway} show={flow === 'unbond'} onClose={() => setFlow(null)} />
|
||||
</NymCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default GatewayCard;
|
||||
@@ -0,0 +1,3 @@
|
||||
import GatewayCard from './GatewayCard';
|
||||
|
||||
export default GatewayCard;
|
||||
@@ -0,0 +1 @@
|
||||
export type GatewayFlow = 'unbond' | null;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { AppContext } from 'src/context/main';
|
||||
import { Box } from '@mui/material';
|
||||
import { useBondingContext, BondingContextProvider } from '../../context';
|
||||
import { PageLayout } from '../../layouts';
|
||||
import BondingCard from './bonding';
|
||||
import MixnodeCard from './mixnode';
|
||||
import GatewayCard from './gateway';
|
||||
|
||||
const Bonding = () => {
|
||||
const { bondedMixnode, bondedGateway, loading } = useBondingContext();
|
||||
|
||||
// TODO display a special UI on loading state
|
||||
return (
|
||||
<PageLayout>
|
||||
{!bondedMixnode && !bondedGateway && <BondingCard />}
|
||||
{bondedMixnode && <MixnodeCard mixnode={bondedMixnode} />}
|
||||
{bondedGateway && <GatewayCard gateway={bondedGateway} />}
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const BondingPage = () => {
|
||||
const { network } = useContext(AppContext);
|
||||
return (
|
||||
<BondingContextProvider network={network}>
|
||||
<Bonding />
|
||||
</BondingContextProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Stack, Typography } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Link } from '@nymproject/react/link/Link';
|
||||
import { NymCard } from 'src/components';
|
||||
import { IdentityKey } from 'src/components/IdentityKey';
|
||||
import { NodeStatus } from 'src/components/NodeStatus';
|
||||
import { BondedMixnode } from '../../../context';
|
||||
import { Node as NodeIcon } from '../../../svg-icons/node';
|
||||
import { Cell, Header, NodeMenu, NodeTable } from '../components';
|
||||
import Unbond from '../unbond';
|
||||
import BondMore from './bond-more';
|
||||
import CompoundRewards from './compound';
|
||||
import NodeSettings from './node-settings';
|
||||
import RedeemRewards from './redeem';
|
||||
import { MixnodeFlow } from './types';
|
||||
|
||||
const headers: Header[] = [
|
||||
{
|
||||
header: 'Stake',
|
||||
id: 'stake',
|
||||
sx: { pl: 0 },
|
||||
},
|
||||
{
|
||||
header: 'Bond',
|
||||
id: 'bond',
|
||||
},
|
||||
{
|
||||
header: 'Stake saturation',
|
||||
id: 'stake-saturation',
|
||||
tooltip: 'TODO', // TODO
|
||||
},
|
||||
{
|
||||
header: 'PM',
|
||||
id: 'profit-margin',
|
||||
tooltip:
|
||||
'The percentage of the node rewards that you as the node operator will take before the rest of the reward is shared between you and the delegators.',
|
||||
},
|
||||
{
|
||||
header: 'Node rewards',
|
||||
id: 'node-rewards',
|
||||
tooltip: 'This is the total rewards for this node in this epoch including delegates and the operators share.',
|
||||
},
|
||||
{
|
||||
header: 'Operator rewards',
|
||||
id: 'operator-rewards',
|
||||
tooltip:
|
||||
'This is your (operator) new rewards including the PM and cost. You can compound your rewards manually every epoch or unbond your node to redeem them.',
|
||||
},
|
||||
{
|
||||
header: 'No. delegators',
|
||||
id: 'delegators',
|
||||
},
|
||||
{
|
||||
id: 'menu-button',
|
||||
size: 'small',
|
||||
sx: { width: 34, maxWidth: 34 },
|
||||
},
|
||||
];
|
||||
|
||||
const MixnodeCard = ({ mixnode }: { mixnode: BondedMixnode }) => {
|
||||
const { stake, bond, stakeSaturation, profitMargin, nodeRewards, operatorRewards, delegators } = mixnode;
|
||||
const [flow, setFlow] = useState<MixnodeFlow>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const cells: Cell[] = [
|
||||
{
|
||||
cell: `${stake.amount} ${stake.denom}`,
|
||||
id: 'stake-cell',
|
||||
sx: { pl: 0 },
|
||||
},
|
||||
{
|
||||
cell: `${bond.amount} ${bond.denom}`,
|
||||
id: 'bond-cell',
|
||||
},
|
||||
{
|
||||
cell: `${stakeSaturation}%`,
|
||||
id: 'stake-saturation-cell',
|
||||
color: stakeSaturation > 100 ? theme.palette.nym.nymWallet.selectionChance.underModerate : undefined,
|
||||
},
|
||||
{
|
||||
cell: `${profitMargin}%`,
|
||||
id: 'pm-cell',
|
||||
},
|
||||
{
|
||||
cell: `${nodeRewards.amount} ${nodeRewards.denom}`,
|
||||
id: 'node-rewards-cell',
|
||||
},
|
||||
{
|
||||
cell: `${operatorRewards.amount} ${operatorRewards.denom}`,
|
||||
id: 'operator-rewards-cell',
|
||||
},
|
||||
{
|
||||
cell: delegators,
|
||||
id: 'delegators-cell',
|
||||
},
|
||||
{
|
||||
cell: <NodeMenu onFlowChange={(newFlow) => setFlow(newFlow)} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NymCard
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<NodeStatus status={mixnode.status} />
|
||||
<Typography variant="h5">Monster node</Typography>
|
||||
<IdentityKey identityKey={mixnode.identityKey} />
|
||||
</Stack>
|
||||
}
|
||||
Action={
|
||||
<Button variant="text" color="secondary" onClick={() => setFlow('nodeSettings')} startIcon={<NodeIcon />}>
|
||||
Node settings
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<NodeTable headers={headers} cells={cells} />
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
Check more stats of your node on the{' '}
|
||||
<Link href="url" target="_blank">
|
||||
explorer
|
||||
</Link>
|
||||
</Typography>
|
||||
<NodeSettings mixnode={mixnode} show={flow === 'nodeSettings'} onClose={() => setFlow(null)} />
|
||||
<BondMore mixnode={mixnode} show={flow === 'bondMore'} onClose={() => setFlow(null)} />
|
||||
<RedeemRewards mixnode={mixnode} show={flow === 'redeem'} onClose={() => setFlow(null)} />
|
||||
<Unbond node={mixnode} show={flow === 'unbond'} onClose={() => setFlow(null)} />
|
||||
<CompoundRewards mixnode={mixnode} show={flow === 'compound'} onClose={() => setFlow(null)} />
|
||||
</NymCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default MixnodeCard;
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Box, Divider, Stack, Typography } from '@mui/material';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
import { CurrencyInput, TextFieldInput } from '../../components';
|
||||
import schema from './schema';
|
||||
import { AppContext } from '../../../../context';
|
||||
import { TokenPoolSelector } from '../../../../components';
|
||||
import { SimpleModal } from '../../../../components/Modals/SimpleModal';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (bond: DecCoin, signature: string) => void;
|
||||
currentBond: DecCoin;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
amount: DecCoin;
|
||||
tokenPool: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const BondModal = ({ open, onClose, onConfirm, currentBond }: Props) => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
const { userBalance, clientDetails } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
onOk={handleSubmit(async (data) => onConfirm(data.amount, data.signature))}
|
||||
header="Bond more"
|
||||
subHeader="Bond more tokens on your node and receive more rewards"
|
||||
okLabel="Next"
|
||||
okDisabled={Boolean(errors?.amount || errors?.signature)}
|
||||
>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<form>
|
||||
<Stack direction="row" spacing={2}>
|
||||
{userBalance.originalVesting && (
|
||||
<TokenPoolSelector onSelect={(pool) => setValue('tokenPool', pool)} disabled={false} />
|
||||
)}
|
||||
<CurrencyInput
|
||||
control={control}
|
||||
required
|
||||
fullWidth
|
||||
label="Amount"
|
||||
name="amount"
|
||||
currencyDenom={clientDetails?.display_mix_denom}
|
||||
errorMessage={errors.amount?.amount?.message}
|
||||
/>
|
||||
</Stack>
|
||||
<TextFieldInput
|
||||
name="signature"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Signature"
|
||||
placeholder="Signature"
|
||||
error={Boolean(errors.signature)}
|
||||
helperText={errors.signature?.message}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mt: 2.5 }}
|
||||
/>
|
||||
</form>
|
||||
<Stack direction="row" justifyContent="space-between" mt={3}>
|
||||
<Typography fontWeight={600}>Account balance</Typography>
|
||||
<Typography fontWeight={600} textTransform="uppercase">
|
||||
{userBalance.balance?.printable_balance || 0}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography>Current bond</Typography>
|
||||
<Typography>{`${currentBond.amount} ${currentBond.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography fontWeight={400}>Est. fee for this transaction will be calculated in the next page</Typography>
|
||||
</Box>
|
||||
</SimpleModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BondModal;
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { DecCoin, TransactionExecuteResult } from '@nymproject/types';
|
||||
import { Link } from '@nymproject/react/link/Link';
|
||||
import { Typography } from '@mui/material';
|
||||
import { ErrorOutline } from '@mui/icons-material';
|
||||
import { AppContext, BondedMixnode, urls, useBondingContext } from '../../../../context';
|
||||
import SummaryModal from './SummaryModal';
|
||||
import { ConfirmationModal } from '../../../../components';
|
||||
import BondModal from './BondModal';
|
||||
|
||||
interface Props {
|
||||
mixnode: BondedMixnode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BondMore = ({ mixnode, show, onClose }: Props) => {
|
||||
const [addBond, setAddBond] = useState<DecCoin>({ amount: '0', denom: 'nym' });
|
||||
const [signature, setSignature] = useState<string>();
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [tx, setTx] = useState<TransactionExecuteResult>();
|
||||
|
||||
const { network } = useContext(AppContext);
|
||||
const { bondMore, error } = useBondingContext();
|
||||
|
||||
const submit = async () => {
|
||||
const txResult = await bondMore(signature as string, addBond);
|
||||
if (txResult) {
|
||||
setStep(3);
|
||||
}
|
||||
setTx(txResult);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setAddBond({ amount: '0', denom: 'nym' });
|
||||
setSignature('');
|
||||
setStep(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BondModal
|
||||
open={show && step === 1}
|
||||
onClose={onClose}
|
||||
onConfirm={async (bond, sig) => {
|
||||
setAddBond(bond);
|
||||
setSignature(sig);
|
||||
setStep(2);
|
||||
}}
|
||||
currentBond={mixnode.bond}
|
||||
/>
|
||||
<SummaryModal
|
||||
open={show && step === 2}
|
||||
onClose={reset}
|
||||
onConfirm={submit}
|
||||
onCancel={() => setStep(1)}
|
||||
currentBond={mixnode.bond}
|
||||
addBond={addBond}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
open={show && step === 3 && !error}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Bonding successful"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography sx={{ mb: 2 }}>This operation can take up to one hour to process</Typography>
|
||||
<Link href={`${urls(network).blockExplorer}/transaction/${tx?.transaction_hash}`} noIcon>
|
||||
View on blockchain
|
||||
</Link>
|
||||
</ConfirmationModal>
|
||||
{error && (
|
||||
<ConfirmationModal
|
||||
open={show}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Operation failed"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography variant="caption">Error: {error}</Typography>
|
||||
<ErrorOutline color="error" />
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BondMore;
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as React from 'react';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
import { useEffect } from 'react';
|
||||
import { ErrorOutline } from '@mui/icons-material';
|
||||
import { SimpleModal } from '../../../../components/Modals/SimpleModal';
|
||||
import { useBondingContext } from '../../../../context';
|
||||
import { ConfirmationModal } from '../../../../components';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
currentBond: DecCoin;
|
||||
addBond: DecCoin;
|
||||
}
|
||||
|
||||
const SummaryModal = ({ open, onClose, onConfirm, onCancel, currentBond, addBond }: Props) => {
|
||||
const { getFee, fee, error } = useBondingContext();
|
||||
|
||||
const fetchFee = async () => {
|
||||
await getFee('bondMore', {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFee();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ConfirmationModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onConfirm={onClose}
|
||||
title="Operation failed"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography variant="caption">Error: {error}</Typography>
|
||||
<ErrorOutline color="error" />
|
||||
</ConfirmationModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOk={onConfirm}
|
||||
onBack={onCancel}
|
||||
header="Bond mor details"
|
||||
okLabel="Confirm"
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Current bond</Typography>
|
||||
<Typography fontWeight={400}>{`${currentBond.amount} ${currentBond.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Additional bond</Typography>
|
||||
<Typography fontWeight={400}>{`${addBond.amount} ${addBond.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Fee for this operation</Typography>
|
||||
<Typography fontWeight={400}>{fee ? `${fee.amount?.amount} ${fee.amount?.denom}` : ''}</Typography>
|
||||
</Stack>
|
||||
</SimpleModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SummaryModal;
|
||||
@@ -0,0 +1,3 @@
|
||||
import BondMore from './BondMore';
|
||||
|
||||
export default BondMore;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { object, string } from 'yup';
|
||||
import { validateAmount, validateKey } from '../../../../utils';
|
||||
|
||||
const schema = object().shape({
|
||||
amount: object().shape({
|
||||
amount: string()
|
||||
.required('An amount is required')
|
||||
.test('valid-amount', 'Pledge error', async function isValidAmount(this, value) {
|
||||
const isValid = await validateAmount(value || '', '0.01');
|
||||
if (!isValid) {
|
||||
return this.createError({ message: 'A valid amount is required (min 0.01)' });
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
}),
|
||||
signature: string()
|
||||
.required('Signature is required')
|
||||
.test('valid-signature', 'A valid signature is required', (value) => validateKey(value || '', 64)),
|
||||
});
|
||||
|
||||
export default schema;
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { TransactionExecuteResult } from '@nymproject/types';
|
||||
import { Link } from '@nymproject/react/link/Link';
|
||||
import { Typography } from '@mui/material';
|
||||
import { ErrorOutline } from '@mui/icons-material';
|
||||
import { AppContext, BondedMixnode, urls, useBondingContext } from '../../../../context';
|
||||
import SummaryModal from './SummaryModal';
|
||||
import { ConfirmationModal } from '../../../../components';
|
||||
|
||||
interface Props {
|
||||
mixnode: BondedMixnode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CompoundRewards = ({ mixnode, show, onClose }: Props) => {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [tx, setTx] = useState<TransactionExecuteResult>();
|
||||
|
||||
const { network } = useContext(AppContext);
|
||||
const { compoundRewards, error, fee, getFee } = useBondingContext();
|
||||
|
||||
const fetchFee = async () => {
|
||||
await getFee('compoundRewards', {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFee();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
const txResult = await compoundRewards();
|
||||
if (txResult) {
|
||||
setStep(2);
|
||||
}
|
||||
setTx(txResult?.[0]);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStep(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SummaryModal
|
||||
open={show && step === 1}
|
||||
onClose={reset}
|
||||
onConfirm={submit}
|
||||
onCancel={reset}
|
||||
rewards={mixnode.operatorRewards}
|
||||
fee={fee?.amount}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
open={show && step === 2}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Rewards compounded successfuly"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography sx={{ mb: 2 }}>This operation can take up to one hour to process</Typography>
|
||||
<Link href={`${urls(network).blockExplorer}/transaction/${tx?.transaction_hash}`} noIcon>
|
||||
View on blockchain
|
||||
</Link>
|
||||
</ConfirmationModal>
|
||||
{error && (
|
||||
<ConfirmationModal
|
||||
open={show}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Operation failed"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography variant="caption">Error: {error}</Typography>
|
||||
<ErrorOutline color="error" />
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompoundRewards;
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
import { SimpleModal } from '../../../../components/Modals/SimpleModal';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
rewards: DecCoin;
|
||||
fee?: DecCoin | null;
|
||||
}
|
||||
|
||||
const SummaryModal = ({ open, onClose, onConfirm, onCancel, rewards, fee }: Props) => (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOk={onConfirm}
|
||||
onBack={onCancel}
|
||||
header="Compound rewards"
|
||||
subHeader="Get more rewards by compounding"
|
||||
okLabel="Compound"
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Operator rewards</Typography>
|
||||
<Typography fontWeight={400}>{`${rewards.amount} ${rewards.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Fee for this operation</Typography>
|
||||
<Typography fontWeight={400}>{fee ? `${fee?.amount} ${fee?.denom}` : ''}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography fontWeight={400}>Rewards will be added to your bonding pool</Typography>
|
||||
</SimpleModal>
|
||||
);
|
||||
|
||||
export default SummaryModal;
|
||||
@@ -0,0 +1,3 @@
|
||||
import CompoundRewards from './CompoundRewards';
|
||||
|
||||
export default CompoundRewards;
|
||||
@@ -0,0 +1,3 @@
|
||||
import MixnodeCard from './MixnodeCard';
|
||||
|
||||
export default MixnodeCard;
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { DecCoin, TransactionExecuteResult } from '@nymproject/types';
|
||||
import { Link } from '@nymproject/react/link/Link';
|
||||
import { Typography } from '@mui/material';
|
||||
import { ErrorOutline } from '@mui/icons-material';
|
||||
import ProfitMarginModal from './ProfitMarginModal';
|
||||
import { AppContext, BondedMixnode, urls, useBondingContext } from '../../../../context';
|
||||
import SummaryModal from './SummaryModal';
|
||||
import { ConfirmationModal } from '../../../../components';
|
||||
|
||||
interface Props {
|
||||
mixnode: BondedMixnode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// TODO fetch real estimated operator reward for 10% PM
|
||||
const MOCK_ESTIMATED_OP_REWARD: DecCoin = { amount: '42', denom: 'nym' };
|
||||
|
||||
const NodeSettings = ({ mixnode, show, onClose }: Props) => {
|
||||
const [status, setStatus] = useState<'success' | 'error'>();
|
||||
const [profitMargin, setProfitMargin] = useState<number>();
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [tx, setTx] = useState<TransactionExecuteResult>();
|
||||
|
||||
const { network } = useContext(AppContext);
|
||||
const { updateMixnode, error, fee, getFee } = useBondingContext();
|
||||
|
||||
const submit = async () => {
|
||||
const txResult = await updateMixnode(profitMargin as number);
|
||||
if (txResult) {
|
||||
setStatus('success');
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
setTx(txResult);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setProfitMargin(0);
|
||||
setStep(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfitMarginModal
|
||||
open={show && step === 1}
|
||||
onClose={onClose}
|
||||
onConfirm={async (pm) => {
|
||||
setProfitMargin(pm);
|
||||
setStep(2);
|
||||
}}
|
||||
estimatedOpReward={MOCK_ESTIMATED_OP_REWARD}
|
||||
currentPm={mixnode.profitMargin}
|
||||
/>
|
||||
<SummaryModal
|
||||
open={show && step === 2}
|
||||
onClose={reset}
|
||||
onConfirm={submit}
|
||||
onCancel={() => setStep(1)}
|
||||
currentPm={mixnode.profitMargin}
|
||||
newPm={profitMargin as number}
|
||||
fee={fee?.amount}
|
||||
/>
|
||||
{status === 'success' && (
|
||||
<ConfirmationModal
|
||||
open={show}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Operation successful"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography sx={{ mb: 2 }}>This operation can take up to one hour to process</Typography>
|
||||
<Link href={`${urls(network).blockExplorer}/transaction/${tx?.transaction_hash}`} noIcon>
|
||||
View on blockchain
|
||||
</Link>
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<ConfirmationModal
|
||||
open={show}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Operation failed"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography variant="caption">Error: {error}</Typography>
|
||||
<ErrorOutline color="error" />
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeSettings;
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
import { Box, Divider, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { TextFieldInput } from '../../components';
|
||||
import getSchema from './schema';
|
||||
import { SimpleModal } from '../../../../components/Modals/SimpleModal';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (pm: number) => void;
|
||||
estimatedOpReward: DecCoin;
|
||||
currentPm: number;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
profitMargin: number;
|
||||
}
|
||||
|
||||
const NodeSettingsModal = ({ open, onClose, onConfirm, estimatedOpReward, currentPm }: Props) => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(getSchema(currentPm)),
|
||||
defaultValues: {
|
||||
profitMargin: currentPm,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
onOk={handleSubmit(async (data) => onConfirm(data.profitMargin))}
|
||||
header="Node Settings"
|
||||
subHeader="System Variables"
|
||||
okLabel="Next"
|
||||
okDisabled={Boolean(errors?.profitMargin)}
|
||||
>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<form>
|
||||
<TextFieldInput
|
||||
name="profitMargin"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
label="Set profit margin"
|
||||
placeholder="Profit Margin"
|
||||
error={Boolean(errors.profitMargin)}
|
||||
helperText={
|
||||
errors.profitMargin
|
||||
? errors.profitMargin.message
|
||||
: 'Your new profit margin will be applied in the next epoch'
|
||||
}
|
||||
required
|
||||
muiTextFieldProps={{ fullWidth: true }}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
</form>
|
||||
<Stack direction="row" justifyContent="space-between" mt={3}>
|
||||
<Tooltip
|
||||
title="Estimated total reward in an epoch for this profit margin if your node is selected in the active set."
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Typography fontWeight={400}>Estimated operator reward for 10% PM</Typography>
|
||||
</Tooltip>
|
||||
<Typography fontWeight={400}>{`~${estimatedOpReward.amount} ${estimatedOpReward.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography fontWeight={400}>Est. fee for this transaction will be calculated in the next page</Typography>
|
||||
</Box>
|
||||
</SimpleModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeSettingsModal;
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
import { SimpleModal } from '../../../../components/Modals/SimpleModal';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
currentPm: number;
|
||||
newPm: number;
|
||||
fee?: DecCoin | null;
|
||||
}
|
||||
|
||||
const SummaryModal = ({ open, onClose, onConfirm, onCancel, currentPm, newPm, fee }: Props) => (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOk={onConfirm}
|
||||
onBack={onCancel}
|
||||
header="Profit margin change"
|
||||
subHeader="System Variables"
|
||||
okLabel="Confirm"
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Current profit margin</Typography>
|
||||
<Typography fontWeight={400}>{`${currentPm}%`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>New profit margin</Typography>
|
||||
<Typography fontWeight={400}>{`${newPm}%`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Fee for this operation</Typography>
|
||||
<Typography fontWeight={400}>{fee ? `${fee?.amount} ${fee?.denom}` : ''}</Typography>
|
||||
</Stack>
|
||||
</SimpleModal>
|
||||
);
|
||||
|
||||
export default SummaryModal;
|
||||
@@ -0,0 +1,3 @@
|
||||
import NodeSettings from './NodeSettings';
|
||||
|
||||
export default NodeSettings;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { number, object } from 'yup';
|
||||
|
||||
const getSchema = (currentPm: number) =>
|
||||
object().shape({
|
||||
profitMargin: number().required('Profit Percentage is required').min(0).max(100).notOneOf([currentPm]),
|
||||
});
|
||||
|
||||
export default getSchema;
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { TransactionExecuteResult } from '@nymproject/types';
|
||||
import { Link } from '@nymproject/react/link/Link';
|
||||
import { Typography } from '@mui/material';
|
||||
import { ErrorOutline } from '@mui/icons-material';
|
||||
import { AppContext, BondedMixnode, urls, useBondingContext } from '../../../../context';
|
||||
import SummaryModal from './SummaryModal';
|
||||
import { ConfirmationModal } from '../../../../components';
|
||||
|
||||
interface Props {
|
||||
mixnode: BondedMixnode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const RedeemRewards = ({ mixnode, show, onClose }: Props) => {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [tx, setTx] = useState<TransactionExecuteResult>();
|
||||
|
||||
const { network } = useContext(AppContext);
|
||||
const { redeemRewards, error, fee, getFee } = useBondingContext();
|
||||
|
||||
const fetchFee = async () => {
|
||||
await getFee('redeemRewards', {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFee();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
const txResult = await redeemRewards();
|
||||
if (txResult) {
|
||||
setStep(2);
|
||||
}
|
||||
setTx(txResult?.[0]);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStep(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SummaryModal
|
||||
open={show && step === 1}
|
||||
onClose={reset}
|
||||
onConfirm={submit}
|
||||
onCancel={reset}
|
||||
rewards={mixnode.operatorRewards}
|
||||
fee={fee?.amount}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
open={show && step === 2}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Rewards redemption succesfull"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography sx={{ mb: 2 }}>This operation can take up to one hour to process</Typography>
|
||||
<Link href={`${urls(network).blockExplorer}/transaction/${tx?.transaction_hash}`} noIcon>
|
||||
View on blockchain
|
||||
</Link>
|
||||
</ConfirmationModal>
|
||||
{error && (
|
||||
<ConfirmationModal
|
||||
open={show}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Operation failed"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography variant="caption">Error: {error}</Typography>
|
||||
<ErrorOutline color="error" />
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedeemRewards;
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import { SimpleModal } from '../../../../components/Modals/SimpleModal';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
rewards: DecCoin;
|
||||
fee?: DecCoin | null;
|
||||
}
|
||||
|
||||
const SummaryModal = ({ open, onClose, onConfirm, onCancel, rewards, fee }: Props) => (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOk={onConfirm}
|
||||
onBack={onCancel}
|
||||
header="Redeem rewards"
|
||||
subHeader="Claim your rewards"
|
||||
okLabel="Redeem rewards"
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Rewards to redeem</Typography>
|
||||
<Typography fontWeight={400}>{`${rewards.amount} ${rewards.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Fee for this operation</Typography>
|
||||
<Typography fontWeight={400}>{fee ? `${fee.amount} ${fee.denom}` : ''}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography fontWeight={400}>Rewards will be transferred to the account you are logged in with</Typography>
|
||||
</SimpleModal>
|
||||
);
|
||||
|
||||
export default SummaryModal;
|
||||
@@ -0,0 +1,3 @@
|
||||
import RedeemRewards from './RedeemRewards';
|
||||
|
||||
export default RedeemRewards;
|
||||
@@ -0,0 +1 @@
|
||||
export type MixnodeFlow = 'nodeSettings' | 'bondMore' | 'unbond' | 'compound' | 'redeem' | null;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DecCoin, TNodeType, TransactionExecuteResult } from '@nymproject/types';
|
||||
|
||||
export type FormStep = 1 | 2 | 3 | 4;
|
||||
export type NodeType = TNodeType;
|
||||
export type BondStatus = 'init' | 'success' | 'error' | 'loading';
|
||||
|
||||
export type ACTIONTYPE =
|
||||
| { type: 'change_bond_type'; payload: NodeType }
|
||||
| { type: 'set_node_data'; payload: NodeData }
|
||||
| { type: 'set_amount_data'; payload: AmountData }
|
||||
| { type: 'set_step'; payload: FormStep }
|
||||
| { type: 'set_tx'; payload: TransactionExecuteResult | undefined }
|
||||
| { type: 'set_error'; payload: string | null | undefined }
|
||||
| { type: 'set_bond_status'; payload: BondStatus }
|
||||
| { type: 'next_step' }
|
||||
| { type: 'prev_step' }
|
||||
| { type: 'show_modal' }
|
||||
| { type: 'close_modal' }
|
||||
| { type: 'reset' };
|
||||
|
||||
export type NodeIdentity = {
|
||||
identityKey: string;
|
||||
sphinxKey: string;
|
||||
signature: string;
|
||||
host: string;
|
||||
version: string;
|
||||
advancedOpt: boolean;
|
||||
mixPort: number;
|
||||
};
|
||||
|
||||
export type MixnodeData = NodeIdentity & {
|
||||
verlocPort: number;
|
||||
httpApiPort: number;
|
||||
};
|
||||
|
||||
export type MixnodeAmount = {
|
||||
amount: DecCoin;
|
||||
tokenPool: string;
|
||||
profitMargin: number;
|
||||
};
|
||||
|
||||
export type GatewayData = NodeIdentity & {
|
||||
location: string;
|
||||
clientsPort: number;
|
||||
};
|
||||
|
||||
export type GatewayAmount = Omit<MixnodeAmount, 'profitMargin'>;
|
||||
|
||||
export type NodeData<N = MixnodeData | GatewayData> = {
|
||||
nodeType: TNodeType;
|
||||
} & N;
|
||||
|
||||
export interface AmountData {
|
||||
amount: DecCoin;
|
||||
tokenPool: string;
|
||||
profitMargin?: number;
|
||||
}
|
||||
|
||||
export interface BondState {
|
||||
showModal: boolean;
|
||||
formStep: FormStep;
|
||||
nodeData?: NodeData;
|
||||
amountData?: MixnodeAmount | GatewayAmount;
|
||||
tx?: TransactionExecuteResult;
|
||||
bondStatus: BondStatus;
|
||||
error?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import { SimpleModal } from '../../../components/Modals/SimpleModal';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
|
||||
export interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
bond: DecCoin;
|
||||
rewards?: DecCoin;
|
||||
fee?: DecCoin | null;
|
||||
}
|
||||
|
||||
const SummaryModal = ({ open, onClose, onConfirm, onCancel, bond, rewards, fee }: Props) => (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOk={onConfirm}
|
||||
onBack={onCancel}
|
||||
header="Unbond"
|
||||
subHeader="Unbond and remove your node from the mixnet"
|
||||
okLabel="Unbond"
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Amount to unbond</Typography>
|
||||
<Typography fontWeight={400}>{`${bond.amount} ${bond.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
{rewards?.amount && (
|
||||
<>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Operator rewards</Typography>
|
||||
<Typography fontWeight={400}>{`${rewards.amount} ${rewards.denom}`}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
</>
|
||||
)}
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography fontWeight={400}>Fee for this operation</Typography>
|
||||
<Typography fontWeight={400}>{fee ? `${fee.amount} ${fee.denom}` : ''}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography fontWeight={400}>Tokens will be transferred to account you are logged in with now</Typography>
|
||||
</SimpleModal>
|
||||
);
|
||||
|
||||
export default SummaryModal;
|
||||
@@ -0,0 +1,131 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { Link } from '@nymproject/react/link/Link';
|
||||
import { Typography } from '@mui/material';
|
||||
import { ErrorOutline } from '@mui/icons-material';
|
||||
import { AppContext, BondedGateway, BondedMixnode, urls, useBondingContext } from '../../../context';
|
||||
import SummaryModal from './SummaryModal';
|
||||
import { ConfirmationModal } from '../../../components';
|
||||
import { LoadingModal } from '../../../components/Modals/LoadingModal';
|
||||
import { NodeType } from '../types';
|
||||
|
||||
interface Props {
|
||||
node: BondedMixnode | BondedGateway;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type UnbondStatus = 'success' | 'error';
|
||||
|
||||
const Unbond = ({ node, show, onClose }: Props) => {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [txHash, setTxHash] = useState<string>();
|
||||
const [status, setStatus] = useState<UnbondStatus>();
|
||||
const [nodeType, setNodeType] = useState<NodeType>('mixnode');
|
||||
|
||||
const { network } = useContext(AppContext);
|
||||
const { fee, getFee, resetFeeState, feeLoading, feeError, loading, unbondMixnode, unbondGateway, error } =
|
||||
useBondingContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (error || feeError) {
|
||||
setStatus('error');
|
||||
}
|
||||
}, [error, feeError]);
|
||||
|
||||
useEffect(() => {
|
||||
if ('profitMargin' in node) {
|
||||
setNodeType('mixnode');
|
||||
} else {
|
||||
setNodeType('gateway');
|
||||
}
|
||||
}, [node]);
|
||||
|
||||
const unbond = async () => {
|
||||
let tx;
|
||||
if (nodeType === 'mixnode') {
|
||||
tx = await unbondMixnode();
|
||||
} else {
|
||||
tx = await unbondGateway();
|
||||
}
|
||||
if (!tx) {
|
||||
setStatus('error');
|
||||
}
|
||||
setStatus('success');
|
||||
setTxHash(tx?.transaction_hash);
|
||||
return tx;
|
||||
};
|
||||
|
||||
const fetchFee = async () => {
|
||||
if (nodeType === 'mixnode') {
|
||||
await getFee('unbondMixnode', {});
|
||||
} else {
|
||||
await getFee('unbondGateway', {});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFee();
|
||||
}, [node]);
|
||||
|
||||
const submit = async () => {
|
||||
if (status === 'error') {
|
||||
// Fetch fee failed
|
||||
return;
|
||||
}
|
||||
unbond();
|
||||
resetFeeState();
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStep(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (feeLoading || loading) return <LoadingModal />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SummaryModal
|
||||
open={show && step === 1}
|
||||
onClose={reset}
|
||||
onConfirm={submit}
|
||||
onCancel={reset}
|
||||
bond={node.bond}
|
||||
rewards={nodeType === 'mixnode' ? (node as BondedMixnode).operatorRewards : undefined}
|
||||
fee={fee?.amount}
|
||||
/>
|
||||
{status === 'success' && (
|
||||
<ConfirmationModal
|
||||
open={show && step === 2}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Unbonding succesfull"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography sx={{ mb: 2 }}>This operation can take up to one hour to process</Typography>
|
||||
<Link href={`${urls(network).blockExplorer}/transaction/${txHash}`} noIcon>
|
||||
View on blockchain
|
||||
</Link>
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<ConfirmationModal
|
||||
open={show}
|
||||
onClose={reset}
|
||||
onConfirm={reset}
|
||||
title="Unbonding failed"
|
||||
confirmButton="Done"
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Typography variant="caption">Error: {error}</Typography>
|
||||
<ErrorOutline color="error" />
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Unbond;
|
||||
@@ -0,0 +1,3 @@
|
||||
import Unbond from './Unbond';
|
||||
|
||||
export default Unbond;
|
||||
@@ -7,3 +7,5 @@ export * from './auth';
|
||||
export * from './settings';
|
||||
export * from './unbond';
|
||||
export * from './delegation';
|
||||
export * from './bonding';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InclusionProbabilityResponse,
|
||||
DecCoin,
|
||||
MixNodeBond,
|
||||
GatewayBond,
|
||||
} from '@nymproject/types';
|
||||
import { Epoch } from 'src/types';
|
||||
import { invokeWrapper } from './wrapper';
|
||||
@@ -25,6 +26,7 @@ export const getAllPendingDelegations = async () =>
|
||||
invokeWrapper<DelegationEvent[]>('get_all_pending_delegation_events');
|
||||
|
||||
export const getMixnodeBondDetails = async () => invokeWrapper<MixNodeBond | null>('mixnode_bond_details');
|
||||
export const getGatewayBondDetails = async () => invokeWrapper<GatewayBond | null>('gateway_bond_details');
|
||||
|
||||
export const getOperatorRewards = async (address: string) =>
|
||||
invokeWrapper<DecCoin>('get_operator_rewards', { address });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Route, Routes } from 'react-router-dom';
|
||||
import { ApplicationLayout } from 'src/layouts';
|
||||
import { Terminal } from 'src/pages/terminal';
|
||||
import { Send } from 'src/components/Send';
|
||||
import { Bond, Balance, InternalDocs, Receive, Unbond, DelegationPage, Admin, Settings } from '../pages';
|
||||
import { Bond, Balance, InternalDocs, Receive, Unbond, DelegationPage, Admin, Settings, BondingPage } from '../pages';
|
||||
|
||||
export const AppRoutes = () => (
|
||||
<ApplicationLayout>
|
||||
@@ -14,6 +14,7 @@ export const AppRoutes = () => (
|
||||
<Route path="/balance" element={<Balance />} />
|
||||
<Route path="/receive" element={<Receive />} />
|
||||
<Route path="/bond" element={<Bond />} />
|
||||
<Route path="/bonding" element={<BondingPage />} />
|
||||
<Route path="/unbond" element={<Unbond />} />
|
||||
<Route path="/delegation" element={<DelegationPage />} />
|
||||
<Route path="/docs" element={<InternalDocs />} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { SvgIcon, SvgIconProps } from '@mui/material';
|
||||
|
||||
export const Bonding = (props: SvgIconProps) => (
|
||||
<SvgIcon {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16 17C14.8954 17 14 16.1046 14 15L14 9C14 7.89543 14.8954 7 16 7L22 7C23.1046 7 24 7.89543 24 9L24 15C24 16.1046 23.1046 17 22 17L16 17ZM16 9L16 15L22 15L22 9L16 9Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 17C0.89543 17 -1.00647e-07 16.1046 -2.24801e-07 15L-8.99206e-07 9C-1.02336e-06 7.89543 0.895429 7 2 7L8 7C9.10457 7 10 7.89543 10 9L10 15C10 16.1046 9.10457 17 8 17L2 17ZM2 9L2 15L8 15L8 9L2 9Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6 12C6 11.4477 6.44772 11 7 11H17C17.5523 11 18 11.4477 18 12C18 12.5523 17.5523 13 17 13H7C6.44772 13 6 12.5523 6 12Z"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
@@ -2,3 +2,4 @@ export * from './delegate';
|
||||
export * from './undelegate';
|
||||
export * from './bond';
|
||||
export * from './unbond';
|
||||
export * from './bonding';
|
||||
|
||||
Vendored
+11
@@ -64,6 +64,17 @@ declare module '@mui/material/styles' {
|
||||
nav: {
|
||||
background: string;
|
||||
};
|
||||
mixnodes: {
|
||||
status: {
|
||||
active: string;
|
||||
standby: string;
|
||||
};
|
||||
};
|
||||
selectionChance: {
|
||||
overModerate: string;
|
||||
moderate: string;
|
||||
underModerate: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,6 +56,17 @@ const darkMode: NymPaletteVariant = {
|
||||
nav: {
|
||||
background: '#292E34',
|
||||
},
|
||||
mixnodes: {
|
||||
status: {
|
||||
active: '#20D073',
|
||||
standby: '#5FD7EF',
|
||||
},
|
||||
},
|
||||
selectionChance: {
|
||||
overModerate: '#20D073',
|
||||
moderate: '#EBA53D',
|
||||
underModerate: '#DA465B',
|
||||
},
|
||||
};
|
||||
|
||||
const lightMode: NymPaletteVariant = {
|
||||
@@ -80,6 +91,17 @@ const lightMode: NymPaletteVariant = {
|
||||
nav: {
|
||||
background: '#FFFFFF',
|
||||
},
|
||||
mixnodes: {
|
||||
status: {
|
||||
active: '#1CBB67',
|
||||
standby: '#55C1D7',
|
||||
},
|
||||
},
|
||||
selectionChance: {
|
||||
overModerate: '#20D073',
|
||||
moderate: '#EBA53D',
|
||||
underModerate: '#DA465B',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -273,6 +295,13 @@ export const getDesignTokens = (mode: PaletteMode): ThemeOptions => {
|
||||
underline: 'none',
|
||||
},
|
||||
},
|
||||
MuiDialogTitle: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
palette,
|
||||
};
|
||||
|
||||
@@ -132,8 +132,8 @@ export const CurrencyFormField: React.FC<{
|
||||
required,
|
||||
endAdornment: showCoinMark && (
|
||||
<InputAdornment position="end">
|
||||
{denom === 'unym' && <CoinMark height="20px" />}
|
||||
{denom !== 'unym' && <span>NYMT</span>}
|
||||
{denom === 'NYM' && <CoinMark height="20px" />}
|
||||
{denom !== 'NYM' && <span>NYMT</span>}
|
||||
</InputAdornment>
|
||||
),
|
||||
...{
|
||||
|
||||
@@ -1157,7 +1157,7 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.2"
|
||||
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7", "@babel/runtime@^7.17.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7", "@babel/runtime@^7.17.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
|
||||
version "7.17.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.2.tgz#66f68591605e59da47523c631416b18508779941"
|
||||
integrity sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==
|
||||
@@ -5279,11 +5279,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2"
|
||||
integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==
|
||||
|
||||
"@types/lodash@^4.14.175":
|
||||
version "4.14.179"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.179.tgz#490ec3288088c91295780237d2497a3aa9dfb5c5"
|
||||
integrity sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w==
|
||||
|
||||
"@types/long@^4.0.1":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
|
||||
@@ -13188,11 +13183,6 @@ locate-path@^6.0.0:
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
|
||||
lodash._reinterpolate@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
|
||||
@@ -18101,12 +18091,12 @@ timsort@^0.3.0:
|
||||
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
|
||||
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
|
||||
|
||||
tiny-invariant@^1.0.2:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9"
|
||||
integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==
|
||||
tiny-case@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03"
|
||||
integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==
|
||||
|
||||
tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3:
|
||||
tiny-warning@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
@@ -19542,17 +19532,13 @@ yocto-queue@^0.1.0:
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
yup@^0.32.9:
|
||||
version "0.32.11"
|
||||
resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5"
|
||||
integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==
|
||||
yup@^1.0.0-beta.4:
|
||||
version "1.0.0-beta.4"
|
||||
resolved "https://registry.yarnpkg.com/yup/-/yup-1.0.0-beta.4.tgz#df10f42df6aa0212c22aab58d6d026b1610ff8a3"
|
||||
integrity sha512-g5uuQH2rN+0Z4L/ix8KoYIS6v7vnrykRzM/u7ExSA0WA33vrw2YOUKShBhaueh9N95oz54slgqBQTHx7E6EHwg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.15.4"
|
||||
"@types/lodash" "^4.14.175"
|
||||
lodash "^4.17.21"
|
||||
lodash-es "^4.17.21"
|
||||
nanoclone "^0.2.1"
|
||||
property-expr "^2.0.4"
|
||||
tiny-case "^1.0.2"
|
||||
toposort "^2.0.2"
|
||||
|
||||
zwitch@^1.0.0:
|
||||
|
||||
Reference in New Issue
Block a user