462 lines
19 KiB
JavaScript
462 lines
19 KiB
JavaScript
import { useEffect, useState, useMemo, useCallback } from "react";
|
|
import { useLocation } from "react-router-dom";
|
|
import { ss58Decode } from "@polkadot-labs/hdkd-helpers";
|
|
import { toHex } from "@polkadot-api/utils";
|
|
import ReactGA from "react-ga4";
|
|
import useSWR from 'swr';
|
|
|
|
import {
|
|
Box,
|
|
Container,
|
|
Typography,
|
|
Skeleton,
|
|
useMediaQuery,
|
|
useTheme,
|
|
} from "@mui/material";
|
|
import SettingsIcon from '@mui/icons-material/Settings';
|
|
|
|
import Paper from "../../components/Paper/Paper";
|
|
import Metric from "../../components/Metric/Metric";
|
|
import PageTitle from "../../components/PageTitle/PageTitle";
|
|
import GhostStyledIcon from "../../components/Icon/GhostIcon";
|
|
import MetricCollection from "../../components/Metric/MetricCollection";
|
|
import LinearProgressBar from "../../components/Progress/LinearProgressBar";
|
|
import SwapCard from "../../components/Swap/SwapCard";
|
|
import SwapCollection from "../../components/Swap/SwapCollection";
|
|
import TokenStack from "../../components/TokenStack/TokenStack";
|
|
import DataRow from "../../components/DataRow/DataRow";
|
|
import { PrimaryButton } from "../../components/Button";
|
|
|
|
import { BridgeInSettingsModal } from "./BridgeSettingsModal";
|
|
|
|
import {
|
|
ghost,
|
|
useGatekeeperApy,
|
|
useGatekeeperAddress,
|
|
useGatekeeperMinimum,
|
|
} from "../../hooks/staking";
|
|
|
|
import { useTokenSymbol, useBalance } from "../../hooks/tokens";
|
|
import { useUnstableProvider } from "../../hooks/ghost";
|
|
import { useLocalStorage } from "../../hooks/localstorage";
|
|
import { prettifySeconds } from "../../helpers/timeUtil";
|
|
import { DecimalBigNumber } from "../../helpers/DecimalBigNumber";
|
|
import { formatNumber, formatCurrency } from "../../helpers";
|
|
import { GATEKEEPER_ADDRESSES } from "../../constants/addresses";
|
|
import { networkAvgBlockSpeed } from "../../constants";
|
|
|
|
const DECIMALS_PREFIX = "bridge-in-decimals";
|
|
|
|
const BridgeStatusCard = ({ totalValidators, pendingValidators, tapestryDraft }) => {
|
|
const theme = useTheme();
|
|
const progressPercent = totalValidators > 0
|
|
? (pendingValidators / totalValidators) * 100
|
|
: 0;
|
|
|
|
return (
|
|
<Box mt="30px" sx={{ mb: { xs: "0px", md: "-30px" }}}>
|
|
<Box display="flex" justifyContent="space-between" alignItems="center" mt="2px">
|
|
<Typography variant="caption" fontWeight="bold">
|
|
{tapestryDraft ? "Round 2: Merkle Root Verification" : "Round 1: Block Consensus"}
|
|
</Typography>
|
|
<Typography variant="caption">
|
|
{tapestryDraft
|
|
? `Block: ${formatNumber(tapestryDraft.median)} (${pendingValidators} out of ${totalValidators} validators)`
|
|
: `Blocks Pending... (${pendingValidators} out of ${totalValidators} validators)`
|
|
}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<LinearProgressBar
|
|
variant="determinate"
|
|
value={progressPercent}
|
|
height={6}
|
|
barColor={tapestryDraft
|
|
? theme.colors.feedback.success
|
|
: theme.colors.feedback.error
|
|
}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const BridgeIn = ({ chainId, address, config, connect }) => {
|
|
const location = useLocation();
|
|
const { useStorageValue, setStorageValue, tryStoreInHistory, CONSTANTS } = useLocalStorage();
|
|
|
|
const isSmallScreen = useMediaQuery("(max-width: 650px)");
|
|
const isSemiSmallScreen = useMediaQuery("(max-width: 480px)");
|
|
const isVerySmallScreen = useMediaQuery("(max-width: 379px)");
|
|
|
|
const { casperApi, client, account } = useUnstableProvider();
|
|
const { gatekeepedApy } = useGatekeeperApy(chainId);
|
|
|
|
const [nextEraIn, setNextEraIn] = useState(null);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [isPending, setIsPending] = useState(false);
|
|
const [amount, setAmount] = useState("");
|
|
const [receiver, setReceiver] = useState(account);
|
|
const [customReceiver, setCustomReceiver] = useState(account);
|
|
|
|
const { symbol: ghstSymbol } = useTokenSymbol(chainId, "GHST");
|
|
const { balance: ghstBalance, refetch: ghstBalanceRefetch } = useBalance(chainId, "GHST", address);
|
|
const { gatekeeperAddress } = useGatekeeperAddress(chainId);
|
|
const { existentialDeposit } = useGatekeeperMinimum(chainId);
|
|
|
|
const { data: formatDecimals } = useStorageValue(chainId, address, DECIMALS_PREFIX, "5");
|
|
|
|
useEffect(() => {
|
|
ReactGA.send({ hitType: "pageview", page: location.pathname });
|
|
}, [location]);
|
|
|
|
const bridgeNumbers = useMemo(() => {
|
|
const connectedNetworks = Object.keys(GATEKEEPER_ADDRESSES).length;
|
|
const number = 1 + connectedNetworks * 3;
|
|
return `(${number}, ${number})`;
|
|
}, [chainId]);
|
|
|
|
const { data: chainProperties } = useSWR(
|
|
client ? ['chainProperties', client] : null,
|
|
() => client.getChainSpecData(),
|
|
{
|
|
revalidateOnFocus: true
|
|
}
|
|
);
|
|
|
|
const { data: activeEraStart } = useSWR(
|
|
casperApi ? ['activeEra', casperApi] : null,
|
|
() => casperApi.query.Staking.ActiveEra.getValue(),
|
|
{
|
|
refreshInterval: 12000,
|
|
revalidateOnFocus: true
|
|
}
|
|
);
|
|
|
|
const { data: weaverAuthorities } = useSWR(
|
|
casperApi ? ['weaverAuthorities', casperApi] : null,
|
|
() => casperApi.query.GhostWeaver.Authorities.getValue(),
|
|
{
|
|
revalidateOnFocus: true
|
|
}
|
|
);
|
|
|
|
const { data: networkData } = useSWR(
|
|
casperApi ? ['networkData', casperApi, chainId] : null,
|
|
async () => {
|
|
const key = BigInt(chainId);
|
|
const res = await casperApi.query.GhostNetworks.Networks.getValue(key);
|
|
return res;
|
|
},
|
|
{
|
|
revalidateOnFocus: true
|
|
}
|
|
);
|
|
|
|
const { data: totalStaked } = useSWR(
|
|
casperApi && activeEraStart && chainProperties ? ['erasTotalStake', casperApi, activeEraStart, chainProperties] : null,
|
|
async () => {
|
|
const decimals = chainProperties.properties.tokenDecimals;
|
|
const total = await casperApi.query.Staking.ErasTotalStake.getValue(activeEraStart.index);
|
|
return new DecimalBigNumber(total, decimals);
|
|
},
|
|
{
|
|
revalidateOnFocus: true
|
|
}
|
|
);
|
|
|
|
const { data: networkAttestations } = useSWR(
|
|
casperApi ? ['networkAttestations', casperApi, chainId] : null,
|
|
async () => {
|
|
const key = BigInt(chainId);
|
|
const res = await casperApi.query.GhostWeaver.NetworkAttestations.getValue(key);
|
|
return res;
|
|
},
|
|
{
|
|
refreshInterval: 12000,
|
|
revalidateOnFocus: true
|
|
}
|
|
);
|
|
|
|
const { data: tapestryDraft } = useSWR(
|
|
casperApi ? ['tapestryDraft', casperApi, chainId] : null,
|
|
async () => {
|
|
const key = BigInt(chainId);
|
|
const res = await casperApi.query.GhostWeaver.TapestryDrafts.getValue(key);
|
|
return res;
|
|
},
|
|
{
|
|
refreshInterval: 12000,
|
|
revalidateOnFocus: true
|
|
}
|
|
);
|
|
|
|
const { data: tapestryStrand } = useSWR(
|
|
casperApi ? ['tapestryStrand', casperApi, chainId] : null,
|
|
async () => {
|
|
const key = BigInt(chainId);
|
|
const res = await casperApi.query.GhostWeaver.TapestryStrands.getValue(key);
|
|
return res;
|
|
},
|
|
{
|
|
refreshInterval: 12000,
|
|
revalidateOnFocus: true
|
|
}
|
|
);
|
|
|
|
const tapestryParticipants = useMemo(() => {
|
|
if (!tapestryStrand) return { element: "", onesCount: 0 };
|
|
|
|
let maxOnesCount = 0;
|
|
let bestElement = null;
|
|
|
|
tapestryStrand.forEach(item => {
|
|
const dataArray = item[1]?.data || [];
|
|
const totalOnes = dataArray.reduce((sum, num) => {
|
|
const onesInNumber = num.toString(2).split('1').length - 1;
|
|
return sum + onesInNumber;
|
|
}, 0);
|
|
|
|
if (totalOnes > maxOnesCount) {
|
|
maxOnesCount = totalOnes;
|
|
bestElement = item[0];
|
|
}
|
|
});
|
|
|
|
return {
|
|
element: bestElement,
|
|
onesCount: maxOnesCount
|
|
};
|
|
}, [tapestryStrand]);
|
|
|
|
useEffect(() => {
|
|
if (!activeEraStart || !activeEraStart.start) return;
|
|
|
|
const calculateTime = () => {
|
|
const nowMs = Date.now();
|
|
const diffMs = nowMs - Number(activeEraStart.start);
|
|
return prettifySeconds(Math.max(0, diffMs / 1000));
|
|
};
|
|
|
|
setNextEraIn(calculateTime());
|
|
|
|
const intervalId = setInterval(() => {
|
|
setNextEraIn(calculateTime());
|
|
}, 1000);
|
|
|
|
return () => clearInterval(intervalId);
|
|
}, [activeEraStart]);
|
|
|
|
const blockDelayMetric = useMemo(() => {
|
|
if (!networkData) return null;
|
|
const blockSpeed = networkAvgBlockSpeed(chainId);
|
|
const finalityDelay = networkData.finality_delay;
|
|
const estimatedMins = (blockSpeed * finalityDelay) / 60n;
|
|
return { finalityDelay, text: `~${estimatedMins} mins` }
|
|
}, [chainId, networkData]);
|
|
|
|
const nativeSymbol = useMemo(() => {
|
|
const client = config?.getClient();
|
|
return client?.chain?.nativeCurrency?.symbol;
|
|
}, [config]);
|
|
|
|
const estimatedOutAmount = useMemo(() => {
|
|
const hundredPercent = 1000000000;
|
|
const incomingShare = networkData ? networkData.incoming_share : 0;
|
|
const result = hundredPercent - incomingShare;
|
|
|
|
return amount * result / hundredPercent
|
|
}, [amount, networkData]);
|
|
|
|
const gatekeepersDifferent = useMemo(() => {
|
|
if (!networkData) return false;
|
|
return gatekeeperAddress.toUpperCase() == toHex(networkData.gatekeeper).toUpperCase();
|
|
}, [gatekeeperAddress, networkData]);
|
|
|
|
const preparedAmount = useMemo(() => {
|
|
return new DecimalBigNumber(amount, ghstBalance._decimals);
|
|
}, [amount, ghstBalance]);
|
|
|
|
const receiverHex = useMemo(() => {
|
|
const finalReceiver = customReceiver ? customReceiver : receiver;
|
|
try {
|
|
const [publicKey, prefix] = ss58Decode(finalReceiver);
|
|
if (prefix !== 1995 && prefix !== 1996) return undefined;
|
|
return toHex(publicKey);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}, [receiver, customReceiver]);
|
|
|
|
const actionDisabled = useMemo(() => {
|
|
if (!networkData) return true;
|
|
const differentGatekeepers = gatekeeperAddress.toUpperCase() !== toHex(networkData.gatekeeper).toUpperCase();
|
|
const smallAmount = existentialDeposit.gt(preparedAmount);
|
|
const insufficientAmount = preparedAmount.gt(ghstBalance);
|
|
|
|
return isPending || differentGatekeepers || smallAmount || insufficientAmount || !receiverHex
|
|
}, [isPending, networkData, gatekeeperAddress, existentialDeposit, preparedAmount, ghstBalance, receiverHex]);
|
|
|
|
const setFormatDecimals = useCallback((value) => {
|
|
if (Number(value) <= 17) {
|
|
setStorageValue(chainId, address, DECIMALS_PREFIX, value);
|
|
}
|
|
}, [chainId, address]);
|
|
|
|
const startGhostBridge = async () => {
|
|
setIsPending(true);
|
|
try {
|
|
const txHash = await ghost(chainId, address, receiverHex, preparedAmount._value);
|
|
if (txHash) tryStoreInHistory(chainId, address, CONSTANTS.bridgeIn.key, txHash, CONSTANTS.bridgeIn.name);
|
|
} finally {
|
|
await ghstBalanceRefetch();
|
|
setAmount("");
|
|
setIsPending(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box minHeight="calc(100vh - 43px)">
|
|
<PageTitle name="GHOST Bridge-In" subtitle={`${bridgeNumbers} Bridge-In ${ghstSymbol ? ghstSymbol : "CSPR"} into ${bridgeNumbers} Stake\u00B2`} />
|
|
<Container
|
|
style={{
|
|
paddingLeft: isSmallScreen || isVerySmallScreen ? "0" : "3.3rem",
|
|
paddingRight: isSmallScreen || isVerySmallScreen ? "0" : "3.3rem",
|
|
}}
|
|
>
|
|
<BridgeInSettingsModal
|
|
isOpen={settingsOpen && !isPending}
|
|
onClose={() => setSettingsOpen(false)}
|
|
decimals={formatDecimals}
|
|
setDecimals={setFormatDecimals}
|
|
receiver={customReceiver}
|
|
setReceiver={setCustomReceiver}
|
|
/>
|
|
<Box mt="15px" display="flex" flexDirection="column" justifyContent="space-between" alignItems="center">
|
|
<Paper
|
|
fullWidth
|
|
enableBackground
|
|
headerContent={
|
|
<Box display="flex" gap="5px">
|
|
<Typography>Next Era in</Typography>
|
|
{nextEraIn
|
|
? <Typography>{nextEraIn}</Typography>
|
|
: <Skeleton width="120px" />
|
|
}
|
|
</Box>
|
|
}
|
|
>
|
|
<MetricCollection>
|
|
<Metric
|
|
metric={`${formatNumber(gatekeepedApy, 2)}%`}
|
|
label={`Stake\u00B2 APY`}
|
|
tooltip="some description to be added"
|
|
/>
|
|
|
|
<Metric
|
|
metric={`${formatNumber(totalStaked ? totalStaked.toString() : "0", 2)} ${chainProperties ? chainProperties.properties.tokenSymbol : "CSPR"}`}
|
|
isLoading={!totalStaked}
|
|
label="Bridge Security Stake"
|
|
tooltip="some description to be added"
|
|
/>
|
|
|
|
<Metric
|
|
label="Block Delay"
|
|
tooltip={`some description to be added ${blockDelayMetric ? blockDelayMetric.finalityDelay : "estimating..."}`}
|
|
isLoading={!blockDelayMetric}
|
|
metric={blockDelayMetric?.text}
|
|
/>
|
|
</MetricCollection>
|
|
|
|
<BridgeStatusCard
|
|
tapestryDraft={tapestryDraft}
|
|
totalValidators={weaverAuthorities ? weaverAuthorities.length : 0}
|
|
pendingValidators={
|
|
tapestryDraft
|
|
? tapestryParticipants.onesCount
|
|
: networkAttestations ? networkAttestations.filter(v => v !== undefined).length : 0
|
|
}
|
|
/>
|
|
</Paper>
|
|
|
|
<Box width="100%" maxWidth="420px" mt="15px">
|
|
<Paper
|
|
fullWidth
|
|
enableBackground
|
|
topRight={
|
|
<GhostStyledIcon
|
|
component={SettingsIcon}
|
|
viewBox="0 0 23 23"
|
|
style={{ display: "flex", alignItems: "center", cursor: "pointer" }}
|
|
onClick={() => setSettingsOpen(true)}
|
|
/>
|
|
}
|
|
headerContent={
|
|
<Typography variant="h6">Bridge-In</Typography>
|
|
}
|
|
>
|
|
<Box maxWidth="390px" display="flex" flexDirection="column" justifyContent="center" gap="3px">
|
|
<SwapCollection
|
|
iconNotNeeded
|
|
UpperSwapCard={
|
|
<SwapCard
|
|
maxWidth="390px"
|
|
inputWidth={isVerySmallScreen ? "100px" : isSemiSmallScreen ? "180px" : "250px"}
|
|
id="from"
|
|
token={
|
|
<TokenStack
|
|
chainTokenNames={[nativeSymbol]}
|
|
tokens={[ghstSymbol]}
|
|
sx={{ fontSize: "25px", height: "30px" }}
|
|
/>
|
|
}
|
|
tokenName={ghstSymbol}
|
|
info={formatCurrency(ghstBalance.toString(), formatDecimals, ghstSymbol)}
|
|
endString="Max"
|
|
value={amount}
|
|
endStringOnClick={() => setAmount(ghstBalance.toString())}
|
|
onChange={event => setAmount(event.currentTarget.value)}
|
|
inputProps={{ "data-testid": "fromInput" }}
|
|
/>}
|
|
LowerSwapCard={
|
|
<SwapCard
|
|
maxWidth="390px"
|
|
inputWidth={isVerySmallScreen ? "100px" : isSemiSmallScreen ? "180px" : "250px"}
|
|
id="to"
|
|
token={
|
|
<TokenStack
|
|
chainTokenNames={[nativeSymbol]}
|
|
tokens={["GHST"]}
|
|
sx={{ fontSize: "25px", height: "30px" }}
|
|
/>
|
|
}
|
|
tokenName={"GHST"}
|
|
value={estimatedOutAmount}
|
|
inputProps={{ "data-testid": "toInput" }}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<DataRow
|
|
title={`Min Bridge-In`}
|
|
tooltip="Minimum amount that could be handled by the bridge to avoid dust balances."
|
|
balance={<span>{formatCurrency(existentialDeposit.toString(), formatDecimals, ghstSymbol)}</span>}
|
|
/>
|
|
|
|
<PrimaryButton
|
|
fullWidth
|
|
disabled={actionDisabled}
|
|
onClick={startGhostBridge}
|
|
>
|
|
{isPending ? "Pending..." : "Bridge-In"}
|
|
</PrimaryButton>
|
|
</Box>
|
|
</Paper>
|
|
</Box>
|
|
</Box>
|
|
</Container>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export default BridgeIn;
|