507 lines
20 KiB
JavaScript
507 lines
20 KiB
JavaScript
import { useMemo, useState, useCallback, useEffect } from "react";
|
|
import { Box, Typography, Link, Checkbox, FormControlLabel, useTheme } from "@mui/material";
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { getBlockNumber } from "@wagmi/core";
|
|
import { useConfig } from "wagmi";
|
|
import { ss58Decode } from "@polkadot-labs/hdkd-helpers";
|
|
import { toHex } from "@polkadot-api/utils";
|
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
|
import { CheckBoxOutlineBlank, CheckBoxOutlined } from "@mui/icons-material";
|
|
|
|
import Metric from "../../components/Metric/Metric";
|
|
import Modal from "../../components/Modal/Modal";
|
|
import SwapCard from "../../components/Swap/SwapCard";
|
|
import Token from "../../components/Token/Token";
|
|
import GhostStyledIcon from "../../components/Icon/GhostIcon";
|
|
import { PrimaryButton, SecondaryButton } from "../../components/Button";
|
|
import { GATEKEEPER_ADDRESSES, EMPTY_ADDRESS } from "../../constants/addresses";
|
|
import { GHOST_CONNECT } from "../../constants/ecosystem";
|
|
|
|
import { useLocalStorage } from "../../hooks/localstorage";
|
|
import { useBreakoutModal } from "../../hooks/breakoutModal";
|
|
import { useTokenSymbol, useCirculatingSupply } from "../../hooks/tokens";
|
|
import { useEpoch, useGatekeeperApy, useGatekeeperAddress } from "../../hooks/staking";
|
|
import { useEvmNetwork, useCurrentIndex, useUnstableProvider } from "../../hooks/ghost";
|
|
import { formatNumber, shorten } from "../../helpers";
|
|
import { prettifySecondsInDays } from "../../helpers/timeUtil";
|
|
import { DecimalBigNumber } from "../../helpers/DecimalBigNumber";
|
|
|
|
const BreakoutModal = ({ chainId, address }) => {
|
|
const [step, setStep] = useState(0);
|
|
const [receiver, setReceiver] = useState("");
|
|
const [convertedReceiver, setConvertedReceiver] = useState(undefined);
|
|
|
|
const { symbol: ghstSymbol } = useTokenSymbol(chainId, "GHST");
|
|
const { symbol: ftsoSymbol } = useTokenSymbol(chainId, "FTSO");
|
|
|
|
const evmNetwork = useEvmNetwork({ evmChainId: chainId });
|
|
|
|
const {
|
|
isOpened,
|
|
closeModal: closeModalInner,
|
|
isStakingOpened,
|
|
isClaimBondOpened,
|
|
warmupPeriod,
|
|
setActiveTxIndex,
|
|
defaultFunction,
|
|
executableFunction,
|
|
estimatedAmount
|
|
} = useBreakoutModal();
|
|
|
|
const incomingFee = useMemo(() => {
|
|
return new DecimalBigNumber(
|
|
evmNetwork ? evmNetwork.incoming_fee : 100000000,
|
|
7
|
|
);
|
|
}, [evmNetwork]);
|
|
|
|
const closeModal = () => {
|
|
setActiveTxIndex(-1);
|
|
closeModalPure();
|
|
}
|
|
|
|
const closeModalPure = () => {
|
|
setStep(0);
|
|
setReceiver("");
|
|
closeModalInner();
|
|
}
|
|
|
|
const header = useMemo(() => {
|
|
if (isStakingOpened && warmupPeriod <= 0) return "Stake Warmed-up"
|
|
if (isClaimBondOpened && warmupPeriod <= 0) return "Bond Warmed-up"
|
|
if (isStakingOpened && warmupPeriod > 0) return "Stake in Warm-up"
|
|
if (isClaimBondOpened && warmupPeriod > 0) return "Bond in Warm-up"
|
|
}, [isStakingOpened, isClaimBondOpened, warmupPeriod]);
|
|
|
|
const bridgeNumbers = useMemo(() => {
|
|
const connectedNetworks = Object.keys(GATEKEEPER_ADDRESSES).length;
|
|
const number = 1 + connectedNetworks * 3;
|
|
return `(${number}, ${number})`;
|
|
}, [chainId]);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const [publicKey, prefix] = ss58Decode(receiver);
|
|
if (prefix !== 1995 && prefix !== 1996) {
|
|
throw new Error("bad prefix");
|
|
}
|
|
setConvertedReceiver(toHex(publicKey));
|
|
} catch {
|
|
setConvertedReceiver(undefined);
|
|
}
|
|
}, [receiver]);
|
|
|
|
return (
|
|
<Modal
|
|
headerContent={
|
|
<Box display="flex" justifyContent="center" alignItems="center" gap="15px">
|
|
<Typography variant="h4">{step === 0 ? header : step === 1 ? `Start ${bridgeNumbers} ${"Stake\u00B2"}` : "Bridge Confirmation"}</Typography>
|
|
</Box>
|
|
}
|
|
open={isOpened}
|
|
onClose={closeModal}
|
|
maxWidth="380px"
|
|
minHeight="200px"
|
|
>
|
|
<Box height="420px" display="flex" flexDirection="column" justifyContent="space-between">
|
|
{step === 0
|
|
? <WelcomeView
|
|
isStakingOpened={isStakingOpened}
|
|
chainId={chainId}
|
|
warmupPeriod={warmupPeriod}
|
|
ghstSymbol={ghstSymbol}
|
|
ftsoSymbol={ftsoSymbol}
|
|
bridgeNumbers={bridgeNumbers}
|
|
defaultFunction={defaultFunction}
|
|
goNext={() => setStep(1)}
|
|
closeModal={closeModal}
|
|
/>
|
|
: step === 1
|
|
? <BridgeView
|
|
receiver={receiver}
|
|
setReceiver={setReceiver}
|
|
chainId={chainId}
|
|
bridgeNumbers={bridgeNumbers}
|
|
ghstSymbol={ghstSymbol}
|
|
estimatedAmount={estimatedAmount}
|
|
incomingFee={incomingFee}
|
|
goNext={() => setStep(2)}
|
|
convertedReceiver={convertedReceiver}
|
|
setConvertedReceiver={setConvertedReceiver}
|
|
|
|
/>
|
|
: <ConfirmStep
|
|
chainId={chainId}
|
|
address={address}
|
|
receiver={receiver}
|
|
executableFunction={executableFunction}
|
|
isStakingOpened={isStakingOpened}
|
|
bridgeNumbers={bridgeNumbers}
|
|
incomingFee={incomingFee}
|
|
estimatedAmount={estimatedAmount}
|
|
ghstSymbol={ghstSymbol}
|
|
setActiveTxIndex={setActiveTxIndex}
|
|
closeModal={closeModalPure}
|
|
evmNetwork={evmNetwork}
|
|
convertedReceiver={convertedReceiver}
|
|
/>
|
|
}
|
|
</Box>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
const BridgeView = ({
|
|
chainId,
|
|
receiver,
|
|
setReceiver,
|
|
convertedReceiver,
|
|
setConvertedReceiver,
|
|
bridgeNumbers,
|
|
ghstSymbol,
|
|
estimatedAmount,
|
|
goNext,
|
|
incomingFee
|
|
}) => {
|
|
const theme = useTheme();
|
|
|
|
const config = useConfig();
|
|
const { gatekeeperAddress } = useGatekeeperAddress(chainId);
|
|
|
|
const chainExplorerUrl = useMemo(() => {
|
|
const client = config?.getClient();
|
|
return client?.chain?.blockExplorers?.default?.url;
|
|
}, [config]);
|
|
|
|
return (
|
|
<>
|
|
<Typography>Bridge to start earning {bridgeNumbers} {"Stake\u00B2"} on your {ghstSymbol} balance:</Typography>
|
|
|
|
<Box display="flex" justifyContent="center">
|
|
<Typography variant="h5">{formatNumber(estimatedAmount, 5)} {ghstSymbol}</Typography>
|
|
</Box>
|
|
|
|
<Typography>
|
|
Generate a unique address for per tx with <Link underline="hover" href={GHOST_CONNECT} color={theme.colors.primary[300]}>GHOST Connect</Link> for privacy.
|
|
</Typography>
|
|
|
|
<SwapCard
|
|
id={`bridge-token-receiver`}
|
|
inputWidth={"100%"}
|
|
value={convertedReceiver ? shorten(receiver, 15, -10) : receiver}
|
|
onChange={event => setReceiver(convertedReceiver ? "" : event.currentTarget.value)}
|
|
inputProps={{ "data-testid": "fromInput" }}
|
|
placeholder="GHOST address (sf prefixed)"
|
|
endString={convertedReceiver
|
|
? <GhostStyledIcon color="success" viewBox="0 0 25 25" component={CheckCircleIcon} />
|
|
: undefined
|
|
}
|
|
type="text"
|
|
maxWidth="100%"
|
|
/>
|
|
|
|
<Box display="flex" justifyContent="center" flexDirection="column" alignItems="center">
|
|
<Box width="100%" display="flex" flexDirection="row" justifyContent="space-between">
|
|
<Typography variant="body2">Gatekeeper</Typography>
|
|
<Link
|
|
fontSize="12px"
|
|
lineHeight="15px"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
href={`${chainExplorerUrl}/token/${gatekeeperAddress}`}
|
|
>
|
|
<Typography variant="body2">
|
|
{shorten(gatekeeperAddress, 10, -8)}
|
|
</Typography>
|
|
</Link>
|
|
</Box>
|
|
<Box width="100%" display="flex" flexDirection="row" justifyContent="space-between">
|
|
<Typography variant="body2">Bridge Fee</Typography>
|
|
<Typography variant="body2">{formatNumber(incomingFee, 4)}%</Typography>
|
|
</Box>
|
|
<Box width="100%" display="flex" flexDirection="row" justifyContent="space-between">
|
|
<Typography variant="body2">Est. Time</Typography>
|
|
<Typography variant="body2">20 mins</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<PrimaryButton
|
|
onClick={goNext}
|
|
disabled={convertedReceiver === undefined}
|
|
fullWidth
|
|
>
|
|
Proceed
|
|
</PrimaryButton>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const WelcomeView = ({
|
|
bridgeNumbers,
|
|
goNext,
|
|
isStakingOpened,
|
|
chainId,
|
|
warmupPeriod,
|
|
ghstSymbol,
|
|
ftsoSymbol,
|
|
defaultFunction,
|
|
closeModal
|
|
}) => {
|
|
const [isPending, setIsPending] = useState(false);
|
|
|
|
const { epoch } = useEpoch(chainId);
|
|
const { gatekeeperAddress } = useGatekeeperAddress(chainId);
|
|
const circulatingSupply = useCirculatingSupply(chainId, "STNK");
|
|
const gatekeepedApy = useGatekeeperApy(chainId);
|
|
|
|
const { isExtensionMissing } = useUnstableProvider();
|
|
|
|
const getConnect = () => {
|
|
window.open(GHOST_CONNECT, '_blank', 'noopener,noreferrer');
|
|
closeModal();
|
|
}
|
|
|
|
const apyInner = useMemo(() => {
|
|
let apy = Infinity;
|
|
if (circulatingSupply._value > 0n) {
|
|
const value = epoch.distribute.div(circulatingSupply);
|
|
apy = 100 * (Math.pow(1 + parseFloat(value.toString()), 1095) - 1);
|
|
if (apy === 0) apy = Infinity;
|
|
}
|
|
return apy;
|
|
}, [circulatingSupply, epoch]);
|
|
|
|
const callDefaultFunction = useCallback(async () => {
|
|
setIsPending(true);
|
|
await defaultFunction()();
|
|
setIsPending(false);
|
|
closeModal();
|
|
}, [defaultFunction]);
|
|
|
|
return (
|
|
<>
|
|
<Typography>{warmupPeriod <= 0
|
|
? `You've succesfully warmed-up your ${isStakingOpened ? " " : "bonded "}${ftsoSymbol} ${isStakingOpened ? "(3, 3)" : "(1, 1)"} Staked at:`
|
|
: `${isStakingOpened ? "Stake" : "Bond"} is in warm-up${isStakingOpened ? "" : ", which extends with each purchase"}. Your ${ftsoSymbol} ${isStakingOpened ? "(3, 3)" : "(1, 1)"} is Staked at:`
|
|
}</Typography>
|
|
|
|
<Box display="flex" justifyContent="center">
|
|
<Typography variant="h5">{formatNumber(apyInner, 2)}% APY</Typography>
|
|
</Box>
|
|
|
|
<SecondaryButton
|
|
onClick={() => callDefaultFunction()}
|
|
disabled={isPending || warmupPeriod > 0}
|
|
loading={isPending}
|
|
fullWidth
|
|
>
|
|
{warmupPeriod > 0
|
|
? `Warm-up ends in ${prettifySecondsInDays(epoch.length * warmupPeriod)}`
|
|
: `${isPending ? "Claiming..." : "Claim"} ${isStakingOpened ? "(3, 3) Stake" : "(1, 1) Bond"}`
|
|
}
|
|
</SecondaryButton>
|
|
|
|
<Box display="flex" justifyContent="center" flexDirection="column" alignItems="center">
|
|
<hr style={{ width: "100%" }} />
|
|
<Typography variant="h5">OR</Typography>
|
|
<hr style={{ width: "100%" }} />
|
|
</Box>
|
|
|
|
<Box display="flex" flexDirection="column" justifyContent="space-between" gap="10px">
|
|
<Typography fontWeight="bold">Skip the Warm-up Now!</Typography>
|
|
<Typography>{`Bridge your ${ghstSymbol} to GHOST Chain and start ${bridgeNumbers} ${"Stake\u00B2"} at:`}</Typography>
|
|
</Box>
|
|
|
|
<Box display="flex" justifyContent="center" flexDirection="column" alignItems="center">
|
|
<Typography variant="h5">{formatNumber(apyInner * gatekeepedApy, 2)}% APY</Typography>
|
|
</Box>
|
|
|
|
<PrimaryButton
|
|
disabled={isPending || gatekeeperAddress === EMPTY_ADDRESS}
|
|
onClick={isExtensionMissing ? getConnect : goNext}
|
|
fullWidth
|
|
>
|
|
{isExtensionMissing ? "Get GHOST Connect" : `Start ${bridgeNumbers} ${"Stake\u00B2"}`}
|
|
</PrimaryButton>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const ConfirmStep = ({
|
|
chainId,
|
|
address,
|
|
receiver,
|
|
convertedReceiver,
|
|
executableFunction,
|
|
ghstSymbol,
|
|
bridgeNumbers,
|
|
estimatedAmount,
|
|
bridgingRisk,
|
|
incomingFee,
|
|
setActiveTxIndex,
|
|
closeModal,
|
|
evmNetwork
|
|
}) => {
|
|
const config = useConfig();
|
|
const navigate = useNavigate();
|
|
|
|
const currentSession = useCurrentIndex();
|
|
const { getStorageValue, setStorageValue } = useLocalStorage();
|
|
|
|
const [blockNumber, setBlockNumber] = useState(0n);
|
|
const [isPending, setIsPending] = useState(false);
|
|
const [acknowledgeBridgingRisk, setAcknowledgeBridgingRisk] = useState(false);
|
|
const [acknowledgeWalletCustody, setAcknowledgeWalletCustody] = useState(false);
|
|
|
|
getBlockNumber(config).then(block => setBlockNumber(block));
|
|
|
|
const nativeSymbol = useMemo(() => config?.getClient()?.chain?.nativeCurrency?.symbol, [config]);
|
|
const networkName= useMemo(() => config?.getClient()?.chain?.name.toLowerCase(), [config]);
|
|
|
|
const receivedEstimation = useMemo(() => {
|
|
const decimals = incomingFee._decimals + 2;
|
|
const afterFee = new DecimalBigNumber(
|
|
BigInt(Math.pow(10, decimals) - incomingFee._value),
|
|
decimals
|
|
);
|
|
return estimatedAmount.mul(afterFee);
|
|
}, [incomingFee, estimatedAmount]);
|
|
|
|
const execute = useCallback(async () => {
|
|
setIsPending(true);
|
|
try {
|
|
const txHash = await executableFunction()(convertedReceiver);
|
|
if (txHash) {
|
|
const expectedSessionIndex = (currentSession ?? 0) + (evmNetwork
|
|
? Number((evmNetwork.avg_block_speed * evmNetwork.finality_delay) / (1000n * 14400n))
|
|
: 0);
|
|
|
|
const transaction = {
|
|
receiverAddress: receiver,
|
|
amount: estimatedAmount._value.toString(),
|
|
sessionIndex: expectedSessionIndex,
|
|
transactionHash: txHash,
|
|
blockNumber: blockNumber,
|
|
chainId: chainId,
|
|
bridgeStability: 69, // TODO: avoid stability
|
|
timestamp: Date.now()
|
|
}
|
|
|
|
const storedTransactions = getStorageValue(chainId, address, "bridge-txs", []);
|
|
const newStoredTransactions = [transaction, ...storedTransactions];
|
|
setStorageValue(chainId, address, "bridge-txs", newStoredTransactions);
|
|
|
|
setActiveTxIndex(0);
|
|
navigate(`${networkName}/bridge`);
|
|
}
|
|
} finally {
|
|
setIsPending(false);
|
|
closeModal();
|
|
}
|
|
}, [
|
|
executableFunction,
|
|
convertedReceiver,
|
|
receiver,
|
|
networkName,
|
|
chainId,
|
|
address,
|
|
blockNumber,
|
|
evmNetwork,
|
|
currentSession,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
|
<Box display="flex" flexDirection="column" justifyContent="space-between" alignItems="center" gap="10px">
|
|
<Metric label="To Bridge" metric={formatNumber(estimatedAmount, 5)} />
|
|
<Box width="100%" display="flex" flexDirection="column" justifyContent="center" alignItems="center">
|
|
<Token chainTokenName={nativeSymbol} name={"GHST"} sx={{ fontSize: "55px" }} />
|
|
<Typography>{ghstSymbol}</Typography>
|
|
</Box>
|
|
</Box>
|
|
<GhostStyledIcon sx={{ transform: "rotate(-90deg)" }} component={ArrowDropDownIcon} />
|
|
<Box display="flex" flexDirection="column" justifyContent="space-between" alignItems="center" gap="10px">
|
|
<Metric label="To Receive" metric={formatNumber(receivedEstimation, 5)} />
|
|
<Box width="100%" display="flex" flexDirection="column" justifyContent="center" alignItems="center">
|
|
<Token name={"GHST"} sx={{ fontSize: "55px" }} />
|
|
<Typography>{ghstSymbol}</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Typography>{`You are bridging to GHOST Chain now to claim ${bridgeNumbers} ${"Stake\u00B2"} rewards.`}</Typography>
|
|
|
|
<hr style={{ width: "100%" }} />
|
|
|
|
<Box display="flex" flexDirection="column" justifyContent="space-between" alignItems="left">
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
data-testid="acknowledge-breakout-warm-up"
|
|
checked={acknowledgeBridgingRisk}
|
|
onChange={event => setAcknowledgeBridgingRisk(event.target.checked)}
|
|
icon={<CheckBoxOutlineBlank viewBox="0 0 24 24" />}
|
|
checkedIcon={<CheckBoxOutlined viewBox="0 0 24 24" />}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body2">
|
|
{`I acknowledge decentralized bridging risk.`}
|
|
<Link
|
|
sx={{
|
|
margin: "0px",
|
|
font: "inherit",
|
|
letterSpacing: "inherit",
|
|
textDecoration: "underline",
|
|
textUnderlineOffset: "0.23rem",
|
|
cursor: "pointer",
|
|
textDecorationThickness: "1px",
|
|
"&:hover": {
|
|
textDecoration: "underline",
|
|
}
|
|
}}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
href="https://ghostchain.io/bridge-disclaimer"
|
|
>
|
|
Learn more.
|
|
</Link>
|
|
</Typography>
|
|
}
|
|
sx={{ '& .MuiFormControlLabel-label': { textAlign: "justify" } }}
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
data-testid="acknowledge-breakout-warm-up"
|
|
checked={acknowledgeWalletCustody}
|
|
onChange={event => setAcknowledgeWalletCustody(event.target.checked)}
|
|
icon={<CheckBoxOutlineBlank viewBox="0 0 24 24" />}
|
|
checkedIcon={<CheckBoxOutlined viewBox="0 0 24 24" />}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body2">
|
|
I confirm that recipient address is a self-custodial wallet, not an exchange, third party service, or smart-contract.
|
|
</Typography>
|
|
}
|
|
sx={{ '& .MuiFormControlLabel-label': { textAlign: "justify" } }}
|
|
/>
|
|
</Box>
|
|
|
|
<PrimaryButton
|
|
onClick={() => execute()}
|
|
loading={isPending}
|
|
disabled={isPending || !acknowledgeWalletCustody || !acknowledgeBridgingRisk}
|
|
fullWidth
|
|
>
|
|
{isPending ? "Confirming..." : "I Confirm"}
|
|
</PrimaryButton>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default BreakoutModal;
|