ghost-dao-interface/src/containers/Bridge/BridgeCard.jsx
2026-04-28 15:06:12 +03:00

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>
)
}