ghost-dao-interface/src/containers/Bridge/Bridge.jsx
Uncle Fatso 5c3fa4d679
disable button during pending transaction status
Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
2025-08-21 16:27:31 +03:00

667 lines
34 KiB
JavaScript

import { useEffect, useState, useMemo, useCallback } from "react";
import {
Box,
Container,
Typography,
Link,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableContainer,
useMediaQuery,
useTheme
} from "@mui/material";
import { ss58Decode } from "@polkadot-labs/hdkd-helpers";
import { toHex } from "@polkadot-api/utils";
import { useBlockNumber, useTransactionConfirmations } from "wagmi";
import PendingActionsIcon from '@mui/icons-material/PendingActions';
import PublicIcon from '@mui/icons-material/Public';
import ArrowRightIcon from '@mui/icons-material/ArrowRight';
import HourglassBottomIcon from '@mui/icons-material/HourglassBottom';
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
import ThumbDownAltIcon from '@mui/icons-material/ThumbDownAlt';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CheckIcon from '@mui/icons-material/Check';
import PageTitle from "../../components/PageTitle/PageTitle";
import Paper from "../../components/Paper/Paper";
import SwapCard from "../../components/Swap/SwapCard";
import SwapCollection from "../../components/Swap/SwapCollection";
import TokenStack from "../../components/TokenStack/TokenStack";
import GhostStyledIcon from "../../components/Icon/GhostIcon";
import Modal from "../../components/Modal/Modal";
import { PrimaryButton } from "../../components/Button";
import { GATEKEEPER_ADDRESSES } from "../../constants/addresses";
import { DecimalBigNumber } from "../../helpers/DecimalBigNumber";
import { formatCurrency } from "../../helpers";
import { useTokenSymbol, useBalance } from "../../hooks/tokens";
import { useGatekeeperAddress, ghost } from "../../hooks/staking";
const STORAGE_PREFIX = "storedTransactions"
const Bridge = ({ chainId, address, config, connect }) => {
const theme = useTheme();
const isSmallScreen = useMediaQuery("(max-width: 650px)");
const isSemiSmallScreen = useMediaQuery("(max-width: 480px)");
const isVerySmallScreen = useMediaQuery("(max-width: 379px)");
const [isPending, setIsPending] = useState(false);
const [bridgeAction, setBridgeAction] = useState(true);
const [activeTxIndex, setActiveTxIndex] = useState(-1);
const [txStep, setTxStep] = useState(0);
const [receiver, setReceiver] = useState("");
const [convertedReceiver, setConvertedReceiver] = useState(undefined);
const [amount, setAmount] = useState("");
// ReceivedClaps && ApplausesForTransaction
// session_index
// transaction_hash
// keccak256(receiver, amount, chain_id)
// const initialStoredTransactions = sessionStorage.getItem(STORAGE_PREFIX);
const initialStoredTransactions = JSON.stringify([
{
sessionIndex: 23,
transactionHash: "0x11111111111111111",
receiver: "sfAasdadasasads",
amount: "2312323232223232",
chainId: 11155111,
timestamp: Date.now()
},
{
sessionIndex: 23,
transactionHash: "0x2222222222222222222",
receiver: "sfAasdadasasads",
amount: "1122232232",
chainId: 1,
timestamp: Date.now()
},
{
sessionIndex: 24,
transactionHash: "0x333333333333333333",
receiver: "sfAasdadasasads",
amount: "99999999999999992",
chainId: 11155111,
timestamp: Date.now()
},
{
sessionIndex: 23,
transactionHash: "0x4444444444444444444",
receiver: "sfAasdadasasads",
amount: "2312323232223232",
chainId: 11155111,
timestamp: Date.now()
},
{
sessionIndex: 23,
transactionHash: "0x555555555555555555555",
receiver: "sfAasdadasasads",
amount: "1122232232",
chainId: 1,
timestamp: Date.now()
},
{
sessionIndex: 24,
transactionHash: "0x66666666666666666666666666",
receiver: "sfAasdadasasads",
amount: "99999999999999992",
chainId: 11155111,
timestamp: Date.now()
},
{
sessionIndex: 23,
transactionHash: "0x77777777777777777777777777",
receiver: "sfAasdadasasads",
amount: "2312323232223232",
chainId: 11155111,
timestamp: Date.now()
},
{
sessionIndex: 23,
transactionHash: "0x888888888888888888888888888",
receiver: "sfAasdadasasads",
amount: "1122232232",
chainId: 1,
timestamp: Date.now()
},
{
sessionIndex: 24,
transactionHash: "0x999999999999999999999",
receiver: "sfAasdadasasads",
amount: "99999999999999992",
chainId: 11155111,
timestamp: Date.now()
},
{
sessionIndex: 23,
transactionHash: "0x10101010101010101010",
receiver: "sfAasdadasasads",
amount: "2312323232223232",
chainId: 11155111,
timestamp: Date.now()
},
{
sessionIndex: 23,
transactionHash: "0x12121212121212212",
receiver: "sfAasdadasasads",
amount: "1122232232",
chainId: 1,
timestamp: Date.now()
},
{
sessionIndex: 24,
transactionHash: "0x1313131313131313131",
receiver: "sfAasdadasasads",
amount: "99999999999999992",
chainId: 11155111,
timestamp: Date.now()
}
]);
const [storedTransactions, setStoredTransactions] = useState(
initialStoredTransactions ? JSON.parse(initialStoredTransactions) : []
);
const incomingCommission = new DecimalBigNumber(69n, 100);
const validators = ["first", "second", "third"];
const clappedValidators = 1;
const { data: blockNumber } = useBlockNumber({ watch: true });
// const { data: txtx } = useTransactionConfirmations({
// hash: "0xdb30adfa3bfc58539bc3a9a92f0dcace8f251af90f8a4f525b57d95d28103afc",
// refetchInterval: 5000
// });
// console.log(txtx)
const { gatekeeperAddress } = useGatekeeperAddress(chainId);
const { symbol: ghstSymbol } = useTokenSymbol(chainId, "GHST");
const {
balance: ghstBalance,
refetch: ghstBalanceRefetch
} = useBalance(chainId, "GHST", address);
useEffect(() => {
try {
const [publicKey, prefix] = ss58Decode(receiver);
if (prefix !== 1995 && prefix !== 1996) {
throw new Error("bad prefix");
}
setConvertedReceiver(toHex(publicKey));
} catch {
setConvertedReceiver(undefined);
}
}, [receiver])
const chainExplorerUrl = useMemo(() => {
const client = config?.getClient();
return client?.chain?.blockExplorers?.default?.url;
}, [config]);
const chainName = useMemo(() => {
const client = config?.getClient();
return client?.chain?.name;
}, [config]);
const currentRecord = useMemo(() => {
if (activeTxIndex === -1) return undefined
return storedTransactions.at(activeTxIndex)
}, [activeTxIndex, storedTransactions]);
const gatekeeperAddressEmpty = useMemo(() => {
if (gatekeeperAddress === "0x0000000000000000000000000000000000000000") {
return true;
}
return false;
}, [gatekeeperAddress]);
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 filteredStoredTransactions = useMemo(() => {
return storedTransactions.filter(obj => obj.chainId === chainId);
}, [storedTransactions, chainId]);
const removeStoredRecord = useCallback(() => {
const newStoredTransactions = storedTransactions.filter((_, index) => index !== activeTxIndex)
setStoredTransactions(newStoredTransactions);
sessionStorage.setItem(STORAGE_PREFIX, JSON.stringify(newStoredTransactions));
setActiveTxIndex(-1);
}, [storedTransactions, activeTxIndex, setStoredTransactions, setActiveTxIndex]);
const handleMouseEnter = (index) => {
setTxStep(index);
}
const ghostOrConnect = async () => {
if (address === "") {
connect();
} else {
setIsPending(true);
const txHash = await ghost(chainId, address, convertedReceiver, preparedAmount);
await ghstBalanceRefetch();
setReceiver("");
setAmount("");
setIsPending(false);
}
}
return (
<Box height="calc(100vh - 43px)">
<PageTitle name="GHOST Bridge" subtitle="The only pure Web3 decentralized bridge." />
<Container
style={{
paddingLeft: isSmallScreen || isVerySmallScreen ? "0" : "3.3rem",
paddingRight: isSmallScreen || isVerySmallScreen ? "0" : "3.3rem",
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "calc(100vh - 153px)"
}}
>
<Modal
data-testid="transaction-details-modal"
maxWidth="476px"
headerContent={
<Box display="flex" flexDirection="row">
<Typography variant="h5">
TX Hash&nbsp;
<Link
sx={{
margin: "0px",
font: "inherit",
letterSpacing: "inherit",
textDecoration: "underline",
color: theme.colors.gray[10],
textUnderlineOffset: "0.23rem",
cursor: "pointer",
textDecorationThickness: "3px",
"&:hover": {
textDecoration: "underline",
}
}}
target="_blank"
rel="noopener noreferrer"
href={currentRecord
? `${chainExplorerUrl}/tx/${currentRecord.transactionHash}`
: ""
}
>
{currentRecord?.transactionHash.slice(0, 9)}...{currentRecord?.transactionHash.slice(-9)}
</Link>
</Typography>
</Box>
}
open={activeTxIndex > -1}
onClose={() => setActiveTxIndex(-1)}
minHeight={"100px"}
>
<Box display="flex" gap="1.5rem" flexDirection="column" marginTop=".8rem">
<Box display="flex" flexDirection="row" justifyContent="space-between" alignItems="center">
<Box
sx={{
transition: "all 0.2s ease",
transform: txStep === 0 && "scale(1.1)",
color: txStep === 0 && theme.colors.primary[300]
}}
width="120px"
display="flex"
flexDirection="column"
justifyContent="start"
alignItems="center"
onMouseEnter={() => handleMouseEnter(0)}
>
<GhostStyledIcon
sx={{ width: "35px", height: "35px" }}
viewBox="0 0 25 25"
component={HourglassBottomIcon}
/>
<Typography variant="caption">Finalization</Typography>
<Typography variant="caption">{blockNumber?.toString()} blocks left</Typography>
</Box>
<GhostStyledIcon
sx={{ transition: "all 0.2s ease", opacity: txStep < 1 && "0.2" }}
component={ArrowRightIcon}
/>
<Box
sx={{
transition: "all 0.2s ease",
opacity: txStep < 1 && "0.2",
transform: txStep === 1 && "scale(1.1)",
color: txStep === 1 && theme.colors.primary[300]
}}
width="120px"
display="flex"
flexDirection="column"
justifyContent="start"
alignItems="center"
onMouseEnter={() => handleMouseEnter(1)}
>
<Box display="flex" flexDirection="row" justifyContent="center" alignItems="center">
<GhostStyledIcon
sx={{ width: "35px", height: "35px" }}
viewBox="0 0 25 25"
component={ThumbUpIcon}
/>
<GhostStyledIcon
sx={{ width: "35px", height: "35px" }}
viewBox="0 0 25 25"
component={ThumbDownAltIcon}
/>
</Box>
<Box display="flex" flexDirection="column" justifyContent="center" alignItems="center">
<Typography variant="caption">Slow claps</Typography>
<Typography variant="caption">{clappedValidators} / {validators.length}</Typography>
</Box>
</Box>
<GhostStyledIcon
sx={{ transition: "all 0.2s ease", opacity: txStep < 2 && "0.2" }}
component={ArrowRightIcon}
/>
<Box
sx={{
transition: "all 0.2s ease",
opacity: txStep < 2 && "0.2",
transform: txStep === 2 && "scale(1.1)",
color: txStep === 2 && theme.colors.primary[300]
}}
width="120px"
display="flex"
flexDirection="column"
justifyContent="start"
alignItems="center"
onMouseEnter={() => handleMouseEnter(2)}
>
<GhostStyledIcon
sx={{ width: "35px", height: "35px" }}
viewBox="0 0 25 25"
component={CheckCircleIcon}
/>
<Typography variant="caption">Applaused</Typography>
<Typography variant="caption">Check Receiver</Typography>
</Box>
</Box>
<Box display="flex" flexDirection="column" gap="5px" padding="0.6rem 0">
<Box display="flex" flexDirection="row" justifyContent="space-between">
<Typography variant="body2">Session Index:</Typography>
<Typography variant="body2">{currentRecord?.sessionIndex}</Typography>
</Box>
<Box display="flex" flexDirection="row" justifyContent="space-between">
<Typography variant="body2">Receiver Address:</Typography>
<Typography variant="body2">{currentRecord?.receiver}</Typography>
</Box>
<Box display="flex" flexDirection="row" justifyContent="space-between">
<Typography variant="body2">Sent Amount:</Typography>
<Typography variant="body2">{formatCurrency(
new DecimalBigNumber(
BigInt(currentRecord ? currentRecord.amount : "0"),
18
).toString(), 9, ghstSymbol)
}</Typography>
</Box>
<Box display="flex" flexDirection="row" justifyContent="space-between">
<Typography variant="body2">Executed at:</Typography>
<Typography variant="body2">{
new Date(currentRecord ? currentRecord.timestamp : 0).toLocaleString('en-US')
}</Typography>
</Box>
</Box>
<Box display="flex" flexDirection="column" gap="5px">
<PrimaryButton
fullWidth
disabled={address === ""}
loading={false}
onClick={() => removeStoredRecord()}
>
Erase Record
</PrimaryButton>
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
This will remove the transaction record from the session storage, but it will not cancel the bridge transaction.
</Typography>
</Box>
</Box>
</Modal>
<Box width="100%" maxWidth="476px" display="flex" alignItems="center" justifyContent="center" flexDirection="column">
<Paper
headerContent={
<Box alignItems="center" justifyContent="space-between" display="flex" width="100%">
<Typography variant="h4">{
bridgeAction
? `Bridge $${ghstSymbol}`
: "Transaction history"
}</Typography>
</Box>
}
topRight={
<GhostStyledIcon
component={bridgeAction ? PendingActionsIcon : PublicIcon}
viewBox="0 0 23 23"
style={{ display: "flex", alignItems: "center", cursor: "pointer" }}
onClick={() => setBridgeAction(!bridgeAction)}
/>
}
enableBackground
fullWidth
>
<Box minHeight="300px" display="flex" flexDirection="column" gap="5px">
{bridgeAction && (
<>
<SwapCollection
iconNotNeeded
UpperSwapCard={<SwapCard
id={`bridge-token-receiver`}
inputWidth={isVerySmallScreen ? "100px" : isSemiSmallScreen ? "180px" : "250px"}
value={receiver}
onChange={event => setReceiver(event.currentTarget.value)}
inputProps={{ "data-testid": "fromInput" }}
placeholder="Ghost address (sf prefixed)"
type="text"
/>}
LowerSwapCard={<SwapCard
id={`bridge-token-amount`}
inputWidth={isVerySmallScreen ? "100px" : isSemiSmallScreen ? "180px" : "250px"}
info={`${formatCurrency(ghstBalance.toString(), 4, ghstSymbol)}`}
value={amount}
onChange={event => setAmount(event.currentTarget.value)}
inputProps={{ "data-testid": "fromInput" }}
endString={"Max"}
endStringOnClick={() => setAmount(ghstBalance.toString())}
/>}
/>
<Box
mb="20px"
mt="20px"
flexDirection="column"
display="flex"
gap="10px"
justifyContent="space-between"
>
<Box display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
{gatekeeperAddressEmpty && (
<Box maxWidth="416px" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
<Typography mr="10px" variant="body2" color="textSecondary">
<em>
There is no connected gatekeeper on {chainName} network. Propose gatekeeper on this network to make validators listen to it.
</em>
</Typography>
</Box>
)}
{!gatekeeperAddressEmpty && (
<>
{!isVerySmallScreen && <Typography fontSize="12px" lineHeight="15px">Gatekeeper:</Typography>}
<Link
fontSize="12px"
lineHeight="15px"
target="_blank"
rel="noopener noreferrer"
href={`${chainExplorerUrl}/token/${gatekeeperAddress}`}
>
{gatekeeperAddress.slice(0, 10) + "..." + gatekeeperAddress.slice(-8)}
</Link>
</>
)}
</Box>
{incomingCommission && validators?.length ? (
<Box maxWidth="416px" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
<Typography mr="10px" variant="body2" color="textSecondary">
<em>
GHOST Wallet is not detected on your browser. Download&nbsp;
<Link
target="_blank"
rel="noopener noreferrer"
href="https://git.ghostchain.io/ghostchain/ghost-extension-wallet/releases"
>
GHOST Wallet
</Link>&nbsp; to see full detalization for bridge transaction.
</em>
</Typography>
</Box>
)
: (
<Box display="flex" flexDirection="column" gap="0px">
<Box maxWidth="416px" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
{!isVerySmallScreen && <Typography fontSize="12px" lineHeight="15px">Est. Commission:</Typography>}
<Typography fontSize="12px" lineHeight="15px">unknown</Typography>
</Box>
<Box maxWidth="416px" display="flex" justifyContent={isVerySmallScreen ? "end" : "space-between"}>
{!isVerySmallScreen && <Typography fontSize="12px" lineHeight="15px">Number of validators:</Typography>}
<Typography fontSize="12px" lineHeight="15px">unknown</Typography>
</Box>
</Box>
)}
</Box>
<PrimaryButton
fullWidth
disabled={
isPending || address === "" || gatekeeperAddressEmpty || !convertedReceiver ||
preparedAmount === 0n || ghstBalance._value < preparedAmount
}
loading={isPending}
onClick={() => ghostOrConnect()}
>
{address === "" ?
"Connect"
:
"Bridge"
}
</PrimaryButton>
</>
)}
{!bridgeAction && (
<Box>
<Box display="grid" gridTemplateColumns="1fr 1fr 60px" sx={{ padding: "0.6rem", borderBottom: "1px solid" }}>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>Amount</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>Datetime</Typography>
<Typography variant="subtitle1" sx={{ justifySelf: "center", fontWeight: "bold" }}>Status</Typography>
</Box>
<Box display="flex" flexDirection="column" sx={{
height: "250px",
overflowY: "scroll",
"-ms-overflow-style": "thin !important",
"scrollbar-width": "thin !important",
}}>
{filteredStoredTransactions
.map((obj, idx) => (
<Box
display="grid"
gridTemplateColumns="1fr 1fr 60px"
sx={{
borderBottom: "1px solid",
padding: "0.6rem",
paddingTop: "1.2rem",
cursor: 'pointer',
'&:hover': {
background: theme.colors.paper.cardHover
}
}}
key={obj.transactionHash}
onClick={() => setActiveTxIndex(idx)}
>
<Box display="flex" flexDirection="column" justifyContent="center">
<Typography variant="subtitle2">
{formatCurrency(
new DecimalBigNumber(BigInt(obj.amount), 18).toString(),
3,
ghstSymbol
)}
</Typography>
</Box>
<Box display="flex" flexDirection="column">
<Typography variant="subtitle2">
{new Date(obj.timestamp).toLocaleDateString('en-US')}
</Typography>
<Typography variant="subtitle2">
{new Date(obj.timestamp).toLocaleTimeString('en-US')}
</Typography>
</Box>
<Box display="flex" justifyContent="center" alignItems="center">
<Box
display="flex"
justifyContent="center"
alignItems="center"
sx={{
width: "25px",
height: "25px",
background: idx % 2 === 0
? theme.colors.feedback.warning
: theme.colors.feedback.success,
borderRadius: "100%",
boxShadow: "0px 0px 1px black"
}}
>
{idx % 2 === 0 ?
<GhostStyledIcon
sx={{ width: "20px", height: "20px" }}
viewBox="0 0 25 25"
component={HourglassBottomIcon}
/>
:
<GhostStyledIcon
sx={{ width: "20px", height: "20px" }}
viewBox="0 0 25 25"
component={CheckIcon}
/>
}
</Box>
</Box>
</Box>
))}
</Box>
</Box>
)}
</Box>
</Paper>
</Box>
</Container>
</Box>
)
}
export default Bridge;