376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
import { useMemo, useState, useEffect } from "react";
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Link,
|
|
Skeleton,
|
|
TableContainer,
|
|
Table,
|
|
Paper,
|
|
TableHead,
|
|
TableBody,
|
|
TableRow,
|
|
TableCell,
|
|
Modal,
|
|
useTheme,
|
|
useMediaQuery,
|
|
} from "@mui/material";
|
|
|
|
import { useConfig } from "wagmi";
|
|
import { ss58Decode } from "@polkadot-labs/hdkd-helpers";
|
|
import { toHex } from "@polkadot-api/utils";
|
|
|
|
import { PrimaryButton } from "../../components/Button";
|
|
import GhostStyledIcon from "../../components/Icon/GhostIcon";
|
|
import SwapCard from "../../components/Swap/SwapCard";
|
|
import SwapCollection from "../../components/Swap/SwapCollection";
|
|
|
|
import { ghost } from "../../hooks/staking";
|
|
import { useBalance } from "../../hooks/tokens";
|
|
|
|
import CheckIcon from '@mui/icons-material/Check';
|
|
import HourglassBottomIcon from '@mui/icons-material/HourglassBottom';
|
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
|
|
import { DecimalBigNumber } from "../../helpers/DecimalBigNumber";
|
|
import { formatNumber, formatCurrency, timeConverter } from "../../helpers";
|
|
|
|
import { BridgeRoute } from "./BridgeRoute";
|
|
|
|
const sliceString = (string, first, second) => {
|
|
if (!string) return "";
|
|
return string.slice(0, first) + "..." + string.slice(second);
|
|
}
|
|
|
|
export const BridgeCardAction = ({
|
|
isVerySmallScreen,
|
|
isSemiSmallScreen,
|
|
chainId,
|
|
address,
|
|
ghstSymbol,
|
|
gatekeeperAddressEmpty,
|
|
gatekeeperAddress,
|
|
evmNetwork,
|
|
connect,
|
|
isConfirmed,
|
|
setIsConfirmed,
|
|
openBridgeModal,
|
|
storeTransactionHash,
|
|
}) => {
|
|
const [isPending, setIsPending] = useState(false);
|
|
const [receiver, setReceiver] = useState("");
|
|
const [convertedReceiver, setConvertedReceiver] = useState(undefined);
|
|
const [amount, setAmount] = useState("");
|
|
|
|
const config = useConfig();
|
|
const incomingFee = Number(evmNetwork?.incoming_fee ?? 0n) / 10000000;
|
|
|
|
const {
|
|
balance: ghstBalance,
|
|
refetch: ghstBalanceRefetch
|
|
} = useBalance(chainId, "GHST", address);
|
|
|
|
const chainName = useMemo(() => {
|
|
const client = config?.getClient();
|
|
return client?.chain?.name;
|
|
}, [config]);
|
|
|
|
const chainNativeCurrency = useMemo(() => {
|
|
const client = config?.getClient();
|
|
return client?.chain?.nativeCurrency?.symbol;
|
|
}, [config]);
|
|
|
|
const chainExplorerUrl = useMemo(() => {
|
|
const client = config?.getClient();
|
|
return client?.chain?.blockExplorers?.default?.url;
|
|
}, [config]);
|
|
|
|
const preparedAmount = useMemo(() => {
|
|
try {
|
|
const result = BigInt(parseFloat(amount) * Math.pow(10, 18));
|
|
if (result > ghstBalance._value) {
|
|
return ghstBalance._value;
|
|
}
|
|
return result;
|
|
} catch {
|
|
return 0n;
|
|
}
|
|
}, [amount]);
|
|
|
|
const amountAfterFee = useMemo(() => {
|
|
const convertedAmount = parseFloat(amount);
|
|
if (!convertedAmount) {
|
|
return 0;
|
|
}
|
|
return convertedAmount * (1 - incomingFee / 100);
|
|
}, [amount, incomingFee]);
|
|
|
|
const isDisabled = useMemo(() => {
|
|
let isDisabled = isPending || gatekeeperAddressEmpty;
|
|
if (address !== "") {
|
|
isDisabled = isDisabled
|
|
|| !convertedReceiver
|
|
|| preparedAmount === 0n
|
|
|| ghstBalance._value < preparedAmount;
|
|
}
|
|
return isDisabled;
|
|
}, [isPending, gatekeeperAddressEmpty, address, convertedReceiver, preparedAmount, ghstBalance]);
|
|
|
|
const ghostFunds = async () => {
|
|
setIsPending(true);
|
|
try {
|
|
const txHash = await ghost(chainId, address, convertedReceiver, preparedAmount);
|
|
if (txHash) {
|
|
storeTransactionHash(txHash, receiver, preparedAmount.toString());
|
|
}
|
|
} finally {
|
|
await ghstBalanceRefetch();
|
|
setReceiver("");
|
|
setAmount("");
|
|
setIsPending(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (isConfirmed) {
|
|
setIsConfirmed(false);
|
|
ghostFunds();
|
|
}
|
|
}, [isConfirmed]);
|
|
|
|
const ghostOrConnect = async () => {
|
|
if (address === "") {
|
|
connect();
|
|
} else if (!isConfirmed) {
|
|
openBridgeModal();
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const [publicKey, prefix] = ss58Decode(receiver);
|
|
if (prefix !== 1995 && prefix !== 1996) {
|
|
throw new Error("bad prefix");
|
|
}
|
|
setConvertedReceiver(toHex(publicKey));
|
|
} catch {
|
|
setConvertedReceiver(undefined);
|
|
}
|
|
}, [receiver])
|
|
|
|
return (
|
|
<Box width="100%" height="340px" display="flex" flexDirection="column" justifyContent="space-between">
|
|
<SwapCollection
|
|
iconNotNeeded
|
|
maxWidth="100%"
|
|
UpperSwapCard={<SwapCard
|
|
id={`bridge-token-receiver`}
|
|
inputWidth={"100%"}
|
|
value={convertedReceiver ? sliceString(receiver, 15, -10) : receiver}
|
|
onChange={event => setReceiver(convertedReceiver ? "" : event.currentTarget.value)}
|
|
inputProps={{ "data-testid": "fromInput" }}
|
|
placeholder="GHOST address (sf prefixed)"
|
|
endString={convertedReceiver
|
|
? <GhostStyledIcon color="success" viewBox="0 0 25 25" component={CheckCircleIcon} />
|
|
: undefined
|
|
}
|
|
type="text"
|
|
maxWidth="100%"
|
|
/>}
|
|
LowerSwapCard={<SwapCard
|
|
id={`bridge-token-amount`}
|
|
inputWidth={"100%"}
|
|
info={`${formatCurrency(ghstBalance.toString(), 4, ghstSymbol)}`}
|
|
value={amount}
|
|
onChange={event => setAmount(event.currentTarget.value)}
|
|
inputProps={{ "data-testid": "fromInput" }}
|
|
endString={"Max"}
|
|
endStringOnClick={() => setAmount(ghstBalance.toString())}
|
|
maxWidth="100%"
|
|
/>}
|
|
/>
|
|
<Box
|
|
flexDirection="column"
|
|
display="flex"
|
|
gap="10px"
|
|
justifyContent="space-between"
|
|
>
|
|
<Box width="100%" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
|
|
{gatekeeperAddressEmpty && (
|
|
<Box display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
|
|
<Typography align="justify" mr="10px" variant="body2" color="textSecondary">
|
|
<em>
|
|
There is no connected gatekeeper on {chainName} network yet.
|
|
</em>
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
{!gatekeeperAddressEmpty && (
|
|
<Box width="100%" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
|
|
{!isVerySmallScreen && <Typography fontSize="12px" lineHeight="15px">Gatekeeper:</Typography>}
|
|
<Link
|
|
fontSize="12px"
|
|
lineHeight="15px"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
href={`${chainExplorerUrl}/token/${gatekeeperAddress}`}
|
|
>
|
|
{sliceString(gatekeeperAddress, 10, -8)}
|
|
</Link>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
<Box width="100%" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
|
|
<Box width="100%" display="flex" flexDirection="column" gap="0px">
|
|
<Box maxWidth="100%" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
|
|
{!isVerySmallScreen && <Typography fontSize="12px" lineHeight="15px">Bridge Fee:</Typography>}
|
|
{incomingFee
|
|
? <Typography fontSize="12px" lineHeight="15px">{`${incomingFee.toFixed(4)}%`}</Typography>
|
|
: <Skeleton height={15} width="80px" />
|
|
}
|
|
</Box>
|
|
<Box maxWidth="100%" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
|
|
{!isVerySmallScreen && <Typography fontSize="12px" lineHeight="15px">You will get:</Typography>}
|
|
{incomingFee
|
|
? <Typography fontSize="12px" lineHeight="15px">{amountAfterFee.toFixed(4)} {ghstSymbol}</Typography>
|
|
: <Skeleton height={15} width="80px" />
|
|
}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
<BridgeRoute coinName={ghstSymbol} chainTokenName={chainNativeCurrency} tokens={[ghstSymbol]} />
|
|
</Box>
|
|
<PrimaryButton
|
|
fullWidth
|
|
disabled={isDisabled}
|
|
loading={isPending}
|
|
onClick={() => ghostOrConnect()}
|
|
>
|
|
{address === "" ? "Connect" : isPending ? "Bridging..." : "Bridge" }
|
|
</PrimaryButton>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export const BridgeCardHistory = ({
|
|
isSemiSmallScreen,
|
|
isBigScreen,
|
|
filteredStoredTransactions,
|
|
ghstSymbol,
|
|
blockNumber,
|
|
finalityDelay,
|
|
setActiveTxIndex
|
|
}) => {
|
|
const isVeryBigScreen = useMediaQuery("(max-width: 1360px)");
|
|
|
|
const theme = useTheme();
|
|
const background = (index) => {
|
|
return index % 2 === 1 ? "" : theme.colors.gray[750];
|
|
}
|
|
|
|
return (
|
|
<Box height="320px">
|
|
<TableContainer
|
|
component={Paper}
|
|
className="custom-scrollbar"
|
|
sx={{
|
|
height: "320px",
|
|
overflowY: 'scroll',
|
|
msOverflowStyle: "thin !important",
|
|
scrollbarWidth: "thin !important",
|
|
}}
|
|
>
|
|
<Table sx={{ marginTop: "0px" }} stickyHeader aria-label="sticky available transactions">
|
|
<TableHead>
|
|
<TableRow sx={{ height: "40px" }}>
|
|
{!(isBigScreen ^ isVeryBigScreen) && <TableCell align="left" style={{ padding: "0px", paddingLeft: "16px", fontSize: "12px", borderTopLeftRadius: "3px", background: theme.colors.paper.cardHover }}>
|
|
Transaction
|
|
</TableCell>}
|
|
<TableCell align="center" style={{ padding: "0px", fontSize: "12px", background: theme.colors.paper.cardHover }}>
|
|
Datetime
|
|
</TableCell>
|
|
<TableCell align="center" style={{ padding: "0px", fontSize: "12px", borderTopRightRadius: "3px", background: theme.colors.paper.cardHover }}>
|
|
Status
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{filteredStoredTransactions?.map((obj, idx) => (
|
|
<TableRow
|
|
key={idx}
|
|
sx={{ cursor: "pointer", height: "30px" }}
|
|
id={idx + `--tx-history`}
|
|
data-testid={idx + `--tx-history`}
|
|
onClick={() => setActiveTxIndex(idx)}
|
|
>
|
|
{!(isBigScreen ^ isVeryBigScreen) && <TableCell style={{ background: background(idx) }}>
|
|
<Box display="flex" flexDirection="column" justifyContent="center">
|
|
<Typography variant="caption">
|
|
{formatCurrency(
|
|
new DecimalBigNumber(BigInt(obj.amount), 18).toString(),
|
|
isSemiSmallScreen ? 3 : 8,
|
|
ghstSymbol
|
|
)}
|
|
</Typography>
|
|
<Typography variant="caption">
|
|
{sliceString(
|
|
obj.receiverAddress,
|
|
isSemiSmallScreen ? 5 : 10,
|
|
isSemiSmallScreen ? -3 : -8
|
|
)}
|
|
</Typography>
|
|
</Box>
|
|
</TableCell>}
|
|
|
|
<TableCell style={{ background: background(idx) }}>
|
|
<Box display="flex" flexDirection="column" alignItems="center" paddingLeft="5px">
|
|
<Typography variant="caption">
|
|
{new Date(obj.timestamp).toLocaleDateString('en-US')}
|
|
</Typography>
|
|
<Typography variant="caption">
|
|
{new Date(obj.timestamp).toLocaleTimeString('en-US')}
|
|
</Typography>
|
|
</Box>
|
|
</TableCell>
|
|
|
|
<TableCell style={{ background: background(idx) }}>
|
|
<Box display="flex" justifyContent="center" alignItems="center">
|
|
<Box
|
|
display="flex"
|
|
justifyContent="center"
|
|
alignItems="center"
|
|
sx={{
|
|
width: "20px",
|
|
height: "20px",
|
|
background: Number(blockNumber) - (obj.blockNumber + finalityDelay) < 0
|
|
? theme.colors.feedback.warning
|
|
: theme.colors.feedback.success,
|
|
borderRadius: "100%",
|
|
boxShadow: "0px 0px 1px black"
|
|
}}
|
|
>
|
|
{Number(blockNumber) - (obj.blockNumber + finalityDelay) < 0 ?
|
|
<GhostStyledIcon
|
|
sx={{ width: "15px", height: "15px" }}
|
|
viewBox="0 0 25 25"
|
|
component={HourglassBottomIcon}
|
|
/>
|
|
:
|
|
<GhostStyledIcon
|
|
sx={{ width: "15px", height: "15px" }}
|
|
viewBox="0 0 25 25"
|
|
component={CheckIcon}
|
|
/>
|
|
}
|
|
</Box>
|
|
</Box>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Box>
|
|
)
|
|
}
|