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;