stake-ui/src/components/lockups/VestingAccountCard.tsx

442 lines
14 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import ChartistGraph from 'react-chartist';
import { useDispatch, useSelector } from 'react-redux';
import BN from 'bn.js';
import { useSnackbar } from 'notistack';
import { FixedScaleAxis, IChartOptions, Interpolation } from 'chartist';
import CircularProgress from '@material-ui/core/CircularProgress';
import Card from '@material-ui/core/Card';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import CardContent from '@material-ui/core/CardContent';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import Link from '@material-ui/core/Link';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableRow from '@material-ui/core/TableRow';
import Collapse from '@material-ui/core/Collapse';
import { PublicKey, SYSVAR_CLOCK_PUBKEY } from '@solana/web3.js';
import { TokenInstructions } from '@project-serum/serum';
import { ProgramAccount, State as StoreState } from '../../store/reducer';
import { Network } from '../../store/config';
import { useWallet } from '../common/WalletProvider';
import OwnedTokenAccountsSelect from '../../components/common/OwnedTokenAccountsSelect';
import { withTx } from '../../components/common/Notification';
import { ActionType } from '../../store/actions';
import { getImage } from '../../components/common/RegistrarSelect';
import { useTokenInfos, toDisplay, toDisplayLabel } from '../../utils/tokens';
import {
vestingSigner,
availableForWithdrawal as _availableForWithdrawal,
} from '../../utils/lockup';
type VestingAccountCardProps = {
network: Network;
vesting: ProgramAccount;
};
export default function VestingAccountCard(props: VestingAccountCardProps) {
const { vesting, network } = props;
const { lockupClient, registryClient } = useWallet();
const { enqueueSnackbar } = useSnackbar();
const tokenInfos = useTokenInfos();
const dispatch = useDispatch();
const { accounts, member, mintAccount } = useSelector((state: StoreState) => {
return {
accounts: state.accounts,
member: state.registry.member
? {
publicKey: state.registry.member,
account: state.accounts[state.registry.member.toString()],
}
: undefined,
mintAccount: state.accounts[vesting.account.mint.toString()],
};
});
const [expanded, setExpanded] = useState(false);
const [hover, setHover] = useState(false);
// Whitelisted mints only for now.
const isCustomMint = false;
let mint = accounts[vesting.account.mint.toString()];
const displayFn = mint
? (input: BN) => {
return toDisplay(input, mint.decimals);
}
: (input: BN) => input.toString();
const outstandingLabel = `${displayFn(
vesting.account.outstanding,
)} ${toDisplayLabel(vesting.account.mint)}`;
const startTs = vesting.account.startTs;
const endTs = vesting.account.endTs;
const tsOverflow = endTs.sub(startTs).mod(vesting.account.periodCount);
const shiftedStartTs = startTs.sub(tsOverflow);
const period = endTs.sub(shiftedStartTs).div(vesting.account.periodCount);
// Make the horizontal axis evenly spaced.
//
// Vesting dates assuming we stretch the start date back in time (so that the
// periods are of even length).
const vestingDates = [
...Array(vesting.account.periodCount.toNumber() + 1),
].map((_, idx) => {
return formatDate(
new Date((shiftedStartTs.toNumber() + idx * period.toNumber()) * 1000),
);
});
// Now push the start window forward to the real start date, making the first period shorter.
vestingDates[0] = formatDate(new Date(startTs.toNumber() * 1000));
// Now do the same thing on the vertical axis.
const rewardOverflow = vesting.account.startBalance.mod(
vesting.account.periodCount,
);
const rewardPerPeriod = vesting.account.startBalance
.sub(rewardOverflow)
.div(vesting.account.periodCount)
.toNumber();
const cumulativeVesting = [...Array(vestingDates.length)].map(() => 0);
cumulativeVesting[1] = rewardPerPeriod + rewardOverflow.toNumber();
for (let k = 2; k < cumulativeVesting.length; k += 1) {
cumulativeVesting[k] = cumulativeVesting[k - 1] + rewardPerPeriod;
}
const startLabel = formatDate(
new Date(vesting.account.startTs.toNumber() * 1000),
);
const endLabel = formatDate(
new Date(vesting.account.endTs.toNumber() * 1000),
);
const urlSuffix = `?cluster=${network.explorerClusterSuffix}`;
const [
availableForWithdrawal,
setAvailableForWithdrawal,
] = useState<null | BN>(null);
const [withdrawalAccount, setWithdrawalAccount] = useState<null | PublicKey>(
null,
);
useEffect(() => {
_availableForWithdrawal(lockupClient, vesting.publicKey)
.then((amount: BN) => {
setAvailableForWithdrawal(amount);
})
.catch((err: any) => {
console.error(err);
enqueueSnackbar(
`Error fetching available for withdrawal: ${err.toString()}`,
{
variant: 'error',
},
);
});
}, [lockupClient, vesting, enqueueSnackbar]);
const snack = useSnackbar();
const withdrawEnabled =
withdrawalAccount !== null &&
availableForWithdrawal !== null &&
availableForWithdrawal.gtn(0);
const withdraw = async () => {
await withTx(
snack,
'Withdrawing locked tokens',
'Tokens unlocked',
async () => {
const remainingAccounts = (() => {
if (vesting.account.realizor) {
if (!member) {
// Should never be thrown.
throw new Error('Member account not found');
}
return [
{
pubkey: registryClient.programId,
isSigner: false,
isWritable: false,
},
{ pubkey: member.publicKey, isSigner: false, isWritable: false },
{
pubkey: member.account.balances.spt,
isSigner: false,
isWritable: false,
},
{
pubkey: member.account.balancesLocked.spt,
isSigner: false,
isWritable: false,
},
];
} else {
return undefined;
}
})();
const tx = await lockupClient.rpc.withdraw(availableForWithdrawal!, {
accounts: {
vesting: vesting.publicKey,
beneficiary: lockupClient.provider.wallet.publicKey,
token: withdrawalAccount!,
vault: vesting.account.vault,
vestingSigner: (
await vestingSigner(lockupClient.programId, vesting.publicKey)
).publicKey,
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
clock: SYSVAR_CLOCK_PUBKEY,
},
remainingAccounts,
});
const newVesting = await lockupClient.account.vesting(
vesting.publicKey,
);
dispatch({
type: ActionType.LockupUpdateVesting,
item: {
vesting: {
publicKey: vesting.publicKey,
account: newVesting,
},
},
});
return tx;
},
);
};
const rows = [
{
field: 'Projected unlock',
value:
availableForWithdrawal === null
? null
: displayFn(availableForWithdrawal!),
},
{
field: 'Locked outstanding',
value: displayFn(vesting.account.outstanding),
},
{
field: 'Current balance',
value: displayFn(
vesting.account.outstanding.sub(vesting.account.whitelistOwned),
),
},
{ field: 'Initial lockup', value: displayFn(vesting.account.startBalance) },
{
field: 'Amount unlocked',
value: displayFn(
vesting.account.startBalance.sub(vesting.account.outstanding),
),
},
{
field: 'Whitelist owned',
value: displayFn(vesting.account.whitelistOwned),
},
{ field: 'Period count', value: vesting.account.periodCount.toString() },
{
field: 'Start timestamp',
value: `${new Date(
vesting.account.startTs.toNumber() * 1000,
).toLocaleString()} (${vesting.account.startTs.toString()})`,
},
{
field: 'End timestamp',
value: `${new Date(
vesting.account.endTs.toNumber() * 1000,
).toLocaleString()} (${vesting.account.endTs.toString()})`,
},
{ field: 'Vault', value: vesting.account.vault.toString() },
{
field: 'Realizer program',
value: vesting.account.realizor
? vesting.account.realizor.program.toString()
: 'None',
},
{
field: 'Realizer metadata',
value: vesting.account.realizor
? vesting.account.realizor.metadata.toString()
: 'None',
},
{
field: 'Grantor',
value: vesting.account.grantor.toString(),
},
];
return (
<Card
key={vesting.publicKey.toString()}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
style={{
marginTop: '24px',
cursor: hover ? 'pointer' : 'default',
}}
>
<CardContent style={{ paddingBottom: '16px' }}>
<ListItem onClick={() => setExpanded(!expanded)}>
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
}}
>
<ListItemIcon>
{getImage(tokenInfos.get(vesting.account.mint.toString()), {
marginRight: '16px',
width: '56px',
})}
</ListItemIcon>
<ListItemText
primary={
<Link
href={
`https://explorer.solana.com/account/${vesting.publicKey.toBase58()}` +
urlSuffix
}
target="_blank"
rel="noopener"
>
{vesting.publicKey.toString()}
</Link>
}
secondary={`${startLabel}, ${endLabel} | ${vesting.account.periodCount.toNumber()} periods`}
/>
<div
style={{
marginTop: '6px',
color: 'rgba(0, 0, 0, 0.54)',
display: 'flex',
justifyContent: 'space-between',
flexDirection: 'column',
maxWidth: '200px',
}}
>
<Typography
style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}
variant="body1"
>
{outstandingLabel}
</Typography>
</div>
</div>
</ListItem>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Typography></Typography>
{vestingDates.length <= 15 ? (
<ChartistGraph
data={{
labels: vestingDates,
series: [cumulativeVesting],
}}
options={
{
axisY: {
type: FixedScaleAxis,
low: 0,
high: cumulativeVesting[cumulativeVesting.length - 1],
ticks: cumulativeVesting,
},
lineSmooth: Interpolation.step(),
height: 400,
} as IChartOptions
}
type={'Line'}
/>
) : (
<div style={{ textAlign: 'center', fontWeight: 'bold' }}>
{/* TOOD: graphs for vesting accounts with a lot of periods. */}A
graph isn't available for this account.
</div>
)}
<div>
{isCustomMint && (
<div
style={{
padding: '15px',
}}
>
<b>
Note: custom mints (i.e. not SRM/MSRM) display raw token
amounts without decimals.
</b>
</div>
)}
<Table>
<TableBody>
{rows.map(r => {
return (
<TableRow>
<TableCell>{r.field}</TableCell>
<TableCell align="right">
{r.value === null ? (
<CircularProgress
style={{
height: '20px',
width: '20px',
padding: 0,
}}
/>
) : (
r.value
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div style={{ display: 'flex', marginTop: '15px' }}>
<OwnedTokenAccountsSelect
decimals={mintAccount ? mintAccount.decimals : undefined}
mint={vesting.account.mint}
onChange={(f: PublicKey) => setWithdrawalAccount(f)}
/>
<div style={{ marginLeft: '20px', width: '191px' }}>
<Button
style={{ fontSize: '12px' }}
color="primary"
disabled={!withdrawEnabled}
variant="contained"
onClick={() =>
withdraw().catch(err => {
let msg = err.toString();
if (
msg &&
msg.split('custom program error: 0x78').length === 2
) {
msg = 'Unrealized rewards. Please unstake';
}
enqueueSnackbar(
`Error withdrawing from vesting account: ${err.toString()}`,
{
variant: 'error',
},
);
})
}
>
Unlock tokens
</Button>
</div>
</div>
</div>
</Collapse>
</CardContent>
</Card>
);
}
// TODO: locale format without minutes, hours, seconds?
function formatDate(d: Date): string {
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
}