442 lines
14 KiB
TypeScript
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()}`;
|
|
}
|