import { useEffect, useState, useMemo, useCallback } from "react"; import ReactGA from "react-ga4"; import { Box, Container, Typography, Link, Table, TableBody, TableCell, TableHead, TableRow, TableContainer, useMediaQuery, useTheme } from "@mui/material"; import { ss58Decode, ss58Address } from "@polkadot-labs/hdkd-helpers"; import { toHex } from "@polkadot-api/utils"; import { decodeAddress } from "@polkadot/util-crypto"; import { useTransactionConfirmations } from "wagmi"; import { getBlockNumber } from "@wagmi/core"; import { keccak256 } from "viem"; import { u64, u128 } from "scale-ts"; import ContentPasteIcon from '@mui/icons-material/ContentPaste'; import PendingIcon from '@mui/icons-material/Pending'; import PendingActionsIcon from '@mui/icons-material/PendingActions'; import ArrowBack from '@mui/icons-material/ArrowBack'; 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 HandshakeIcon from '@mui/icons-material/Handshake'; 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 InfoTooltip from "../../components/Tooltip/InfoTooltip"; 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"; import { useEvmNetwork, useClapsInSession, useApplauseThreshold, useReceivedClaps, useApplausesForTransaction, useAuthorities, useCurrentIndex, useUnstableProvider, useMetadata } from "../../hooks/ghost"; const STORAGE_PREFIX = "storedTransactions" const Bridge = ({ chainId, address, config, connect }) => { const theme = useTheme(); const isSmallScreen = useMediaQuery("(max-width: 650px)"); const isSemiSmallScreen = useMediaQuery("(max-width: 540px)"); const isVerySmallScreen = useMediaQuery("(max-width: 379px)"); const [copiedIndex, setCopiedIndex] = useState(null); const [isPending, setIsPending] = useState(false); const [bridgeAction, setBridgeAction] = useState(true); const [activeTxIndex, setActiveTxIndex] = useState(-1); const [receiver, setReceiver] = useState(""); const [convertedReceiver, setConvertedReceiver] = useState(undefined); const [amount, setAmount] = useState(""); const [rotation, setRotation] = useState(0); const [blockNumber, setBlockNumber] = useState(0); const sliceString = (string, first, second) => { if (!string) return ""; return string.slice(0, first) + "..." + string.slice(second); } const initialStoredTransactions = localStorage.getItem(STORAGE_PREFIX); const [storedTransactions, setStoredTransactions] = useState( initialStoredTransactions ? JSON.parse(initialStoredTransactions) : [] ); const { providerDetail } = useUnstableProvider(); const metadata = useMetadata(); const watchTransaction = useMemo(() => { if (activeTxIndex < 0 || activeTxIndex >= storedTransactions?.length) { return undefined } return storedTransactions?.at(activeTxIndex) }, [activeTxIndex, storedTransactions]) const hashedArguments = useMemo(() => { if (!watchTransaction) return undefined const amountEncoded = u128.enc(BigInt(watchTransaction.amount)); const networkIdEncoded = u64.enc(BigInt(chainId)); const addressEncoded = decodeAddress(watchTransaction.receiverAddress, false, 1996); const clapArgsStr = new Uint8Array([ ...addressEncoded, ...amountEncoded, ...networkIdEncoded ]); return keccak256(clapArgsStr) }, [watchTransaction]) const currentSession = useCurrentIndex(); const evmNetwork = useEvmNetwork({ evmChainId: chainId }); const authorities = useAuthorities({ currentSession: watchTransaction?.sessionIndex ?? currentSession }); const clapsInSession = useClapsInSession({ currentSession: watchTransaction?.sessionIndex ?? currentSession }); const appluseThreshold = useApplauseThreshold(); const receivedClaps = useReceivedClaps({ currentSession: watchTransaction?.sessionIndex ?? currentSession, txHash: watchTransaction?.transactionHash, argsHash: hashedArguments }); const transactionApplaused = useApplausesForTransaction({ currentSession: watchTransaction?.sessionIndex ?? currentSession, txHash: watchTransaction?.transactionHash, argsHash: hashedArguments }); const finalityDelay = Number(evmNetwork?.finality_delay ?? 0n); const incomingFee = Number(evmNetwork?.incoming_fee ?? 0n) / 10000000; getBlockNumber(config).then(block => setBlockNumber(block)); const { gatekeeperAddress } = useGatekeeperAddress(chainId); const { symbol: ghstSymbol } = useTokenSymbol(chainId, "GHST"); const { balance: ghstBalance, refetch: ghstBalanceRefetch } = useBalance(chainId, "GHST", address); useEffect(() => { ReactGA.send({ hitType: "pageview", page: "/bridge" }); }, []); useEffect(() => { const interval = setInterval(() => { setRotation((prevRotation) => prevRotation > 0 ? 0 : 180) }, 2000); return () => clearInterval(interval); }, [setRotation]) 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 clapsInSessionLength = useMemo(() => { const disabledIndexes = new Set(clapsInSession?.filter(item => item.at(1).disabled).map(item => item.at(0))); return authorities?.filter((_, idx) => !disabledIndexes.has(idx)).length ?? 0; }, [authorities, clapsInSession]); 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 (!watchTransaction) return undefined const finalization = Math.max(0, (finalityDelay + watchTransaction.blockNumber) - Number(blockNumber)); const receivedClapsLength = receivedClaps?.length ?? 0; const clapsNeeded = Math.floor(clapsInSessionLength * appluseThreshold / 100); const step = finalization > 0 ? 0 : receivedClapsLength < clapsNeeded && !transactionApplaused ? 1 : !transactionApplaused ? 2 : 3; return { ...watchTransaction, finalization, step, } }, [ transactionApplaused, receivedClaps, appluseThreshold, clapsInSessionLength, finalityDelay, watchTransaction, blockNumber ]); 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 selfApplauseUrl = useMemo(() => { if (!currentRecord) return ''; const amount = new DecimalBigNumber(BigInt(currentRecord.amount), 18).toString(); let url = "https://lite.ghostchain.io/#/applause?"; url += `networkId=${currentRecord.chainId}&`; url += `sessionIndex=${currentRecord.sessionIndex}&`; url += `amount=${amount}&`; url += `receiver=${currentRecord.receiverAddress}&`; url += `transactionHash=${currentRecord.transactionHash}`; return url; }, [currentRecord]); const removeStoredRecord = useCallback(() => { const newStoredTransactions = storedTransactions.filter((_, index) => index !== activeTxIndex) setStoredTransactions(newStoredTransactions); localStorage.setItem(STORAGE_PREFIX, JSON.stringify(newStoredTransactions)); setActiveTxIndex(-1); }, [storedTransactions, activeTxIndex, setStoredTransactions, setActiveTxIndex]); const copyToClipboard = (text, index) => { navigator.clipboard.writeText(text).then(() => { setCopiedIndex(index); setTimeout(() => setCopiedIndex(null) , 800); }); }; const ghostOrConnect = async () => { if (address === "") { connect(); } else { setIsPending(true); try { const txHash = await ghost(chainId, address, convertedReceiver, preparedAmount); const transaction = { sessionIndex: currentSession ?? 0, transactionHash: txHash, receiverAddress: receiver, amount: preparedAmount.toString(), chainId: chainId, blockNumber: Number(blockNumber), timestamp: Date.now() } const newStoredTransactions = [...storedTransactions, transaction]; setStoredTransactions(newStoredTransactions); localStorage.setItem(STORAGE_PREFIX, JSON.stringify(newStoredTransactions)); if (providerDetail) { setActiveTxIndex(newStoredTransactions.length - 1) } } finally { await ghstBalanceRefetch(); setReceiver(""); setAmount(""); setIsPending(false); } } } return ( TX Hash  {currentRecord ? sliceString(currentRecord.transactionHash, 10, -8) : ""} } open={activeTxIndex >= 0} onClose={() => setActiveTxIndex(-1)} minHeight={"100px"} > 0 && theme.colors.primary[300] }} width="120px" display="flex" flexDirection="column" justifyContent="start" alignItems="center" > 0 ? `rotate(${rotation}deg)` : "rotate(0deg)" }} viewBox="0 0 25 25" component={HourglassBottomIcon} /> Finalization {(currentRecord?.finalization ?? 0).toString()} blocks left 1 && theme.colors.primary[300] }} width="120px" display="flex" flexDirection="column" justifyContent="start" alignItems="center" > {currentRecord?.step <= 1 ? ( <> ) : ( ) } Slow Claps {receivedClaps?.length ?? 0} / {clapsInSessionLength} = 2 && "scale(1.2)", color: currentRecord?.step >= 2 && theme.colors.primary[300] }} width="120px" display="flex" flexDirection="column" justifyContent="start" alignItems="center" > Applaused { currentRecord?.step === 3 ? "Check Receiver" : "Waiting Room" } {(currentRecord?.step ?? 3) < 3 && (currentSession && currentRecord && currentSession > (currentRecord.sessionIndex ?? 0) + 2) && window.open( selfApplauseUrl, '_blank', 'noopener,noreferrer' )} > Self Applause Your transaction seems to be stuck, possibly because of a problem with some inactive validators on the network. } Session Index: Transaction Watchmen {authorities?.map((authority, idx) => { const authorityAddress = ss58Address(authority.asHex(), 1996); const disabled = clapsInSession?.find((info => info.at(0) === idx))?.at(1)?.disabled; const clapped = receivedClaps?.some(authId => authId === idx); return ( {authorityAddress} ) })} } /> {currentRecord?.sessionIndex} Receiver Address: copyToClipboard(currentRecord ? currentRecord.receiverAddress : "", 0)} > {currentRecord ? sliceString(currentRecord.receiverAddress, 14, -5) : ""} Sent Amount: {formatCurrency( new DecimalBigNumber( BigInt(currentRecord ? currentRecord.amount : "0"), 18 ).toString(), 9, ghstSymbol) } Executed at: { new Date(currentRecord ? currentRecord.timestamp : 0).toLocaleString('en-US') }
Transaction Hash: copyToClipboard(currentRecord ? currentRecord.transactionHash : "", 1)} > {currentRecord ? sliceString(currentRecord.transactionHash, 10, -8) : "0x"} Arguments Hash: copyToClipboard(hashedArguments ? hashedArguments : "", 2)} > {hashedArguments ? sliceString(hashedArguments, 10, -8) : "0x"}
removeStoredRecord()} > Erase Record This will remove the transaction record from the session storage, but it will not cancel the bridge transaction.
{!bridgeAction && ( setBridgeAction(!bridgeAction)} />)} { bridgeAction ? `Bridge $${ghstSymbol}` : "Transaction History" } } topRight={bridgeAction && ( setBridgeAction(!bridgeAction)} />)} enableBackground fullWidth > {bridgeAction && ( <> setReceiver(event.currentTarget.value)} inputProps={{ "data-testid": "fromInput" }} placeholder="Ghost address (sf prefixed)" type="text" maxWidth="446px" />} LowerSwapCard={ setAmount(event.currentTarget.value)} inputProps={{ "data-testid": "fromInput" }} endString={"Max"} endStringOnClick={() => setAmount(ghstBalance.toString())} maxWidth="446px" />} /> {gatekeeperAddressEmpty && ( There is no connected gatekeeper on {chainName} network. Propose gatekeeper on this network to make authorities listen to it. )} {!gatekeeperAddressEmpty && ( <> {!isVerySmallScreen && Gatekeeper:} {sliceString(gatekeeperAddress, 10, -8)} )} {!providerDetail ? ( GHOST Wallet is not detected on your browser. Download  GHOST Wallet   to see full detalization for bridge transaction. ) : metadata ? ( {!isVerySmallScreen && Estimated Fee:} {incomingFee.toFixed(4)}% {!isVerySmallScreen && Finality Delay: } {finalityDelay} blocks
{!isVerySmallScreen && Current GHOST Epoch:} {currentSession ?? 0} {!isVerySmallScreen && Current Validators: Validators {authorities?.map((authority, idx) => { const authorityAddress = ss58Address(authority.asHex(), 1996); const clapInfo = clapsInSession?.find((info => info.at(0) === idx))?.at(1); return (
{authorityAddress}
{clapInfo?.claps ?? 0}
) })}
} />
} {clapsInSessionLength} / {authorities?.length ?? 0}
) : ( Downloading chain metadata, wait please... ) }
ghostOrConnect()} > {address === "" ? "Connect" : "Bridge" } )} {!bridgeAction && ( {!isSemiSmallScreen && ( Amount Datetime Status )} {filteredStoredTransactions .map((obj, idx) => ( setActiveTxIndex(idx)} > {formatCurrency( new DecimalBigNumber(BigInt(obj.amount), 18).toString(), isSemiSmallScreen ? 3 : 8, ghstSymbol )} {sliceString( obj.receiverAddress, isSemiSmallScreen ? 5 : 10, isSemiSmallScreen ? -3 : -8 )} {new Date(obj.timestamp).toLocaleDateString('en-US')} {new Date(obj.timestamp).toLocaleTimeString('en-US')} {Number(blockNumber) - (obj.blockNumber + finalityDelay) < 0 ? : } ))} )}
) } export default Bridge;