ghost-dao-interface/src/containers/Bridge/BridgeIn.jsx
Uncle Fatso 42d5f82040
bridge-in flow finished based on new weaver logic
Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
2026-11-06 17:23:41 +03:00

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;