Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Introduce Security hub #1201

Merged
merged 15 commits into from
Sep 11, 2023
47 changes: 47 additions & 0 deletions components/Allowances/AllowancesMobile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Typography } from '@mui/material';
import { type AllowancesState } from 'hooks/useAllowances';
import { allowanceColumns } from '.';
import { MobileSkeleton } from './Skeletons';
import RevokeButton from './RevokeButton';

const AllowancesMobile = ({ data, loading, update }: AllowancesState) => {
const { t } = useTranslation();
if (loading || !data) return <MobileSkeleton />;

if (data.length === 0) {
return (
<Box px={2} py={4} textAlign="center" fontSize={14}>
{t('No approvals found!')}
</Box>
);
}

return data.map((allowance) => (
<Box
sx={{
'&:not(:last-child)': { borderBottom: 1, borderColor: 'grey.200' },
px: 2,
py: 4,
}}
key={`${allowance.spenderAddress}-${allowance.token}`}
>
<Box display="flex" flexDirection="column" gap={1} mb={3}>
{allowanceColumns().map(({ DisplayComponent, sortKey, title }) => (
<Box display="flex" key={sortKey}>
<Typography color="grey.400" flex={1}>
{title}
</Typography>
<Box display="flex" alignItems="flex-end" flexDirection="column" flex={1}>
<DisplayComponent {...allowance} />
</Box>
</Box>
))}
</Box>
<RevokeButton {...allowance} update={update} fullWidth />
</Box>
));
};

export default memo(AllowancesMobile);
73 changes: 73 additions & 0 deletions components/Allowances/AllowancesTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material';
import { type Allowance, type AllowancesState } from 'hooks/useAllowances';
import useSorting from 'hooks/useSorting';
import TableHeadCell from 'components/common/TableHeadCell';
import { TableSkeleton } from './Skeletons';
import RevokeButton from './RevokeButton';
import { allowanceColumns } from '.';

export const AllowancesTable = ({ data, loading, update }: AllowancesState) => {
const { t } = useTranslation();
const { setOrderBy, sortData, isActive, direction } = useSorting<Allowance>();
const sortedAllowances = data ? sortData(data) : undefined;
return (
<TableContainer>
<Table
sx={{
td: { px: 4, py: 3 },
th: { px: 4, py: 2 },
'tr:last-child td': { border: 0 },
}}
>
<TableHead>
<TableRow>
{allowanceColumns().map(({ title, sortKey }) => (
<TableHeadCell
key={title.trim()}
title={title}
sortActive={isActive(sortKey)}
sortDirection={direction(sortKey)}
sort={() => setOrderBy(sortKey)}
isSortEnabled={!!sortKey}
sx={{
h6: { fontFamily: 'Inter', fontSize: 14, fontWeight: 500 },
'&:first-child': { pl: 4 },
'&:last-child': { pr: 1.5 },
}}
/>
))}
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{loading || !sortedAllowances ? (
<TableSkeleton />
) : sortedAllowances.length > 0 ? (
sortedAllowances.map((row) => (
<TableRow key={`${row.spenderAddress}-${row.token}`}>
{allowanceColumns().map(({ DisplayComponent, sortKey }) => (
<TableCell key={sortKey}>
<DisplayComponent {...row} />
</TableCell>
))}
<TableCell align="right">
<RevokeButton {...row} update={update} />
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} align="center">
{t('No approvals found!')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
);
};

export default memo(AllowancesTable);
37 changes: 37 additions & 0 deletions components/Allowances/Amount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { memo } from 'react';
import { formatUnits } from 'viem';
import { useTranslation } from 'react-i18next';
import { Skeleton, Typography } from '@mui/material';
import { type Allowance } from 'hooks/useAllowances';
import formatNumber from 'utils/formatNumber';

const Amount = ({
allowance,
unlimited,
decimals,
symbol,
allowanceUSD,
}: Pick<Allowance, 'allowance' | 'unlimited' | 'decimals' | 'symbol' | 'allowanceUSD'>) => {
const { t } = useTranslation();
if (allowance === undefined) return <Skeleton />;

if (unlimited)
return (
<Typography fontSize={19} fontWeight={500} mb={2}>
{t('Unlimited')}
</Typography>
);

return (
<>
<Typography fontSize={19} fontWeight={500}>
{formatNumber(formatUnits(allowance, decimals), symbol, true)}
</Typography>
<Typography fontFamily="IBM Plex Mono" fontSize={14} fontWeight={500} color="grey.500">
${formatNumber(formatUnits(allowanceUSD, decimals), symbol, true)}
</Typography>
</>
);
};

export default memo(Amount);
41 changes: 41 additions & 0 deletions components/Allowances/Asset.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React, { memo } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Stack, Typography } from '@mui/material';
import CopyToClipboardButton from 'components/common/CopyToClipboardButton';
import useEtherscanLink from 'hooks/useEtherscanLink';
import { type Allowance } from 'hooks/useAllowances';
import { formatWallet } from 'utils/utils';

const Asset = ({ symbol, token }: Pick<Allowance, 'symbol' | 'token'>) => {
const { address } = useEtherscanLink();
return (
<>
<Stack direction="row" spacing={0.5}>
<Image
src={`/img/${symbol.includes('exa') ? 'exaTokens' : 'assets'}/${symbol}.svg`}
alt={symbol}
width={24}
height={24}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Typography display="inline" alignSelf="center" fontSize={19} fontWeight={500} ml={1}>
{symbol}
</Typography>
</Stack>
<Stack direction="row">
<Link href={address(token)} target="_blank" rel="noopener noreferrer">
<Typography variant="subtitle1" fontSize="14px" color="grey.500" fontWeight={500}>
{formatWallet(token)}
</Typography>
</Link>
<CopyToClipboardButton text={token} />
</Stack>
</>
);
};

export default memo(Asset);
54 changes: 54 additions & 0 deletions components/Allowances/RevokeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { memo, useCallback, useState } from 'react';
import { LoadingButton } from '@mui/lab';
import { useNetwork, useSwitchNetwork } from 'wagmi';
import { waitForTransaction } from '@wagmi/core';
import { useTranslation } from 'react-i18next';
import { Allowance } from 'hooks/useAllowances';
import { useWeb3 } from 'hooks/useWeb3';
import useERC20 from 'hooks/useERC20';

const RevokeButton = ({
token,
spenderAddress,
fullWidth,
update,
}: Pick<Allowance, 'token' | 'spenderAddress'> & {
update: () => Promise<void>;
fullWidth?: boolean;
}) => {
const [loading, setLoading] = useState(false);
const erc20 = useERC20(token);
const { opts, chain } = useWeb3();
const { t } = useTranslation();
const { chain: walletChain } = useNetwork();
const { switchNetwork, isLoading: switchIsLoading } = useSwitchNetwork();

const handleClick = useCallback(async () => {
if (!erc20 || !opts) return;
setLoading(true);
try {
const tx = await erc20.write.approve([spenderAddress, 0n], opts);
await waitForTransaction({ hash: tx });
update();
} catch {
// if request fails, don't do anything
} finally {
setLoading(false);
}
}, [erc20, opts, spenderAddress, update]);

if (chain && chain.id !== walletChain?.id) {
return (
<LoadingButton fullWidth onClick={() => switchNetwork?.(chain.id)} variant="contained" loading={switchIsLoading}>
{t('Please switch to {{network}} network', { network: chain.name })}
</LoadingButton>
);
}
return (
<LoadingButton variant="contained" loading={loading} fullWidth={fullWidth} onClick={handleClick}>
{t('Revoke')}
</LoadingButton>
);
};

export default memo(RevokeButton);
25 changes: 25 additions & 0 deletions components/Allowances/Skeletons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { TableCell, Skeleton, TableRow, Box } from '@mui/material';
import { allowanceColumns } from '.';

const AllowanceSkeleton = () => <Skeleton height={53} />;

export const CellSkeleton = () => (
<TableCell>
<AllowanceSkeleton />
</TableCell>
);

export const TableSkeleton = () =>
[1, 2, 3].map((i) => (
<TableRow key={i}>
{allowanceColumns().map(CellSkeleton)}
<TableCell />
</TableRow>
));

export const MobileSkeleton = () => (
<Box px={2} py={4}>
{allowanceColumns().map(AllowanceSkeleton)}
</Box>
);
30 changes: 30 additions & 0 deletions components/Allowances/Spender.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { memo } from 'react';
import Link from 'next/link';
import { Stack, Typography } from '@mui/material';
import { type Allowance } from 'hooks/useAllowances';
import { formatWallet } from 'utils/utils';
import useEtherscanLink from 'hooks/useEtherscanLink';
import CopyToClipboardButton from 'components/common/CopyToClipboardButton';

const Spender = ({ spenderName, spenderAddress }: Pick<Allowance, 'spenderName' | 'spenderAddress'>) => {
const { address } = useEtherscanLink();
return (
<>
<Typography fontSize={19} fontWeight={500}>
{spenderName}
</Typography>
<Typography fontFamily="IBM Plex Mono" fontSize={14} fontWeight={500} color="grey.500">
<Stack direction="row">
<Link href={address(spenderAddress)} target="_blank" rel="noopener noreferrer">
<Typography variant="subtitle1" fontSize={14} color="grey.500">
{formatWallet(spenderAddress)}
</Typography>
</Link>
<CopyToClipboardButton text={spenderAddress} />
</Stack>
</Typography>
</>
);
};

export default memo(Spender);
78 changes: 78 additions & 0 deletions components/Allowances/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React, { memo } from 'react';
import i18n from 'i18n';
import { useTranslation } from 'react-i18next';
import { Box, Button, Typography, useMediaQuery, useTheme } from '@mui/material';
import { useWeb3 } from 'hooks/useWeb3';
import { useAllowances } from 'hooks/useAllowances';
import AllowancesMobile from 'components/Allowances/AllowancesMobile';
import AllowancesTable from 'components/Allowances/AllowancesTable';
import Spender from 'components/Allowances/Spender';
import Amount from 'components/Allowances/Amount';
import Asset from 'components/Allowances/Asset';

export const allowanceColumns = () =>
[
{
title: i18n.t('Asset'),
sortKey: 'symbol',
DisplayComponent: Asset,
},
{
title: i18n.t('Authorized Amount'),
sortKey: 'allowanceUSD',
DisplayComponent: Amount,
},
{
title: i18n.t('Authorized Spender'),
sortKey: 'spenderName',
DisplayComponent: Spender,
},
] as const;

const Allowances = () => {
const { t } = useTranslation();
const { data, loading, update } = useAllowances();
const { isConnected, connect } = useWeb3();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const AllowancesComponent = isMobile ? AllowancesMobile : AllowancesTable;
return (
<Box display="flex" flexDirection="column" pt={5}>
<Box m="auto" display="flex" flexDirection="column">
<Box>
<Typography variant="h6" fontSize={24} fontWeight={700} mb={3}>
{t('Revoke Allowances')}
</Typography>
<Typography fontSize={16} fontWeight={500} mb={1}>
{t(
"Every time you perform an operation within our Protocol, you need to grant spending permission to the Protocol's smart contracts",
)}
</Typography>
<Typography fontSize={16} fontWeight={500} mb={6}>
{t(
"To ensure your security against potential threats, we recommend checking your allowances regularly and revoking those you don't intend to use in the near future. Please remember that both allowing and revoking spending permissions involve on-chain transactions, which require gas fees.",
)}
</Typography>
</Box>
<Box
bgcolor="components.bg"
borderRadius={2}
gap={2}
boxShadow={'0px 3px 4px 0px rgba(97, 102, 107, 0.10)'}
display="flex"
flexDirection="column"
>
{isConnected ? (
<AllowancesComponent data={data} loading={loading} update={update} />
) : (
<Button onClick={connect} variant="contained" sx={{ mx: 'auto', my: 2 }}>
{t('Connect wallet')}
</Button>
)}
</Box>
</Box>
</Box>
);
};

export default memo(Allowances);
Loading