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); const [withdrawalAccount, setWithdrawalAccount] = useState( 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 ( setHover(true)} onMouseOut={() => setHover(false)} style={{ marginTop: '24px', cursor: hover ? 'pointer' : 'default', }} > setExpanded(!expanded)}>
{getImage(tokenInfos.get(vesting.account.mint.toString()), { marginRight: '16px', width: '56px', })} {vesting.publicKey.toString()} } secondary={`${startLabel}, ${endLabel} | ${vesting.account.periodCount.toNumber()} periods`} />
{outstandingLabel}
{vestingDates.length <= 15 ? ( ) : (
{/* TOOD: graphs for vesting accounts with a lot of periods. */}A graph isn't available for this account.
)}
{isCustomMint && (
Note: custom mints (i.e. not SRM/MSRM) display raw token amounts without decimals.
)} {rows.map(r => { return ( {r.field} {r.value === null ? ( ) : ( r.value )} ); })}
setWithdrawalAccount(f)} />
); } // TODO: locale format without minutes, hours, seconds? function formatDate(d: Date): string { return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`; }