UI - added circle loader and swap progress bar

This commit is contained in:
Kevin Peters 2022-01-23 18:21:23 +00:00
parent 76f4c63198
commit 89c118a37f
8 changed files with 344 additions and 78 deletions

View File

@ -1 +1,3 @@
.env .env
build/
node_modules/

View File

@ -1,5 +1,5 @@
{ {
"name": "Cross Chain Swap", "name": "NativeSwap",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>Cross Chain Swap</title> <title>NativeSwap</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -0,0 +1,11 @@
import "../css/CircleLoader.css";
export default function CircleLoader() {
return (
<div id={"loaderContainer"}>
<div id={"circle"}>
<div id={"inner"}></div>
</div>
</div>
);
}

View File

@ -0,0 +1,89 @@
import { ChainId, CHAIN_ID_POLYGON, isEVMChain } from "@certusone/wormhole-sdk";
import { LinearProgress, makeStyles, Typography } from "@material-ui/core";
import { useEffect, useState } from "react";
import { useEthereumProvider } from "../contexts/EthereumProviderContext";
const useStyles = makeStyles((theme) => ({
root: {
marginTop: theme.spacing(2),
textAlign: "center",
},
message: {
marginTop: theme.spacing(1),
},
}));
export default function TransactionProgress({
chainId,
txBlockNumber,
step,
}: {
chainId: ChainId;
txBlockNumber: number | undefined;
step: number;
}) {
const classes = useStyles();
const { provider } = useEthereumProvider();
const [currentBlock, setCurrentBlock] = useState(0);
useEffect(() => {
if (step !== 1 || !txBlockNumber) return;
if (isEVMChain(chainId) && provider) {
let cancelled = false;
(async () => {
while (!cancelled) {
await new Promise((resolve) => setTimeout(resolve, 500));
try {
const newBlock = await provider.getBlockNumber();
if (!cancelled) {
setCurrentBlock(newBlock);
}
} catch (e) {
console.error(e);
}
}
})();
return () => {
cancelled = true;
};
}
}, [step, chainId, provider, txBlockNumber]);
const blockDiff =
txBlockNumber !== undefined && txBlockNumber && currentBlock
? currentBlock - txBlockNumber
: 0;
const expectedBlocks = 15;
let value;
let valueBuffer;
let message;
switch (step) {
case 1:
value = (blockDiff / expectedBlocks) * 50;
valueBuffer = 50;
message = `Waiting for ${blockDiff} / ${expectedBlocks} confirmations on ${
chainId === CHAIN_ID_POLYGON ? "Polygon" : "Ethereum"
}...`;
break;
case 2:
value = 50;
valueBuffer = 100;
message = "Waiting for relayer to complete swap...";
break;
case 3:
value = 100;
valueBuffer = 100;
message = "";
break;
}
return (
<div className={classes.root}>
<LinearProgress
variant="buffer"
value={value}
valueBuffer={valueBuffer}
/>
<Typography variant="body2" className={classes.message}>
{message}
</Typography>
</div>
);
}

View File

@ -0,0 +1,90 @@
:root {
--basis: linear-gradient(
160deg,
rgba(69, 74, 117, 0.473) 0%,
rgba(98, 104, 143, 0.445) 100%
),
linear-gradient(
45deg,
rgba(153, 69, 255, 0.411) 0%,
rgba(0, 209, 139, 0.404) 100%
);
/* --gradient1: rgb(26, 212, 150); */
/* --gradient2: rgb(176, 139, 221); */
--gradient1: rgb(117, 228, 187);
--gradient2: rgb(193, 164, 230);
}
#loaderContainer {
display: flex;
width: max-content;
height: max-content;
justify-content: center;
align-items: center;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes switch {
0% {
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 200px;
height: 200px;
box-shadow: 0 -130px 0 -75px var(--gradient1);
}
25% {
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 200px;
height: 200px;
box-shadow: 0 -130px 0 -75px var(--gradient1);
}
50% {
top: calc(100% - 55px);
width: 50px;
height: 50px;
box-shadow: 0 -130px 0 75px var(--gradient1);
transform: translateX(-50%) translateY(0);
}
75% {
top: calc(100% - 55px);
width: 50px;
height: 50px;
box-shadow: 0 -130px 0 75px var(--gradient1);
transform: translateX(-50%) translateY(0);
}
100% {
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 200px;
height: 200px;
box-shadow: 0 -130px 0 -75px var(--gradient1);
}
}
#circle {
width: 325px;
height: 325px;
display: block;
background: var(--basis);
border-radius: 500%;
position: relative;
animation: rotation 2s linear infinite;
}
#inner {
width: 200px;
height: 200px;
background: var(--gradient2);
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
border-radius: 100%;
box-shadow: 0 -130px 0 -75px #222;
animation: switch 8s ease-in-out infinite;
}

View File

@ -17,7 +17,6 @@ export default async function getIsTransferCompletedEvmWithRetry(
provider, provider,
signedVAA signedVAA
); );
console.log("getIsTransferCompletedEth", result);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }

View File

@ -1,4 +1,5 @@
import { import {
Collapse,
Container, Container,
Link, Link,
makeStyles, makeStyles,
@ -33,6 +34,9 @@ import { Alert } from "@material-ui/lab";
import parseError from "../utils/parseError"; import parseError from "../utils/parseError";
import Settings from "../components/Settings"; import Settings from "../components/Settings";
import getIsTransferCompletedEvmWithRetry from "../utils/getIsTransferCompletedWithRetry"; import getIsTransferCompletedEvmWithRetry from "../utils/getIsTransferCompletedWithRetry";
import CircleLoader from "../components/CircleLoader";
import { ArrowForward, CheckCircleOutlineRounded } from "@material-ui/icons";
import SwapProgress from "../components/SwapProgress";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
bg: { bg: {
@ -115,6 +119,16 @@ const useStyles = makeStyles((theme) => ({
margin: "1rem", margin: "1rem",
display: "inline-block", display: "inline-block",
}, },
loaderHolder: {
display: "flex",
justifyContent: "center",
flexDirection: "column",
alignItems: "center",
},
successIcon: {
color: COLORS.green,
fontSize: "200px",
},
})); }));
const switchProviderNetwork = async ( const switchProviderNetwork = async (
@ -136,10 +150,11 @@ const switchProviderNetwork = async (
export default function Home() { export default function Home() {
const classes = useStyles(); const classes = useStyles();
const [sourceTokenInfo, setSourceTokenInfo] = useState(WMATIC_TOKEN_INFO); const [sourceTokenInfo, setSourceTokenInfo] = useState(MATIC_TOKEN_INFO);
const [targetTokenInfo, setTargetTokenInfo] = useState(WETH_TOKEN_INFO); const [targetTokenInfo, setTargetTokenInfo] = useState(ETH_TOKEN_INFO);
const [amountIn, setAmountIn] = useState(""); const [amountIn, setAmountIn] = useState("");
const [amountOut, setAmountOut] = useState("0.0"); const [amountInUST, setAmountInUST] = useState("");
const [amountOut, setAmountOut] = useState("");
const [deadline, setDeadline] = useState("30"); const [deadline, setDeadline] = useState("30");
const [slippage, setSlippage] = useState("1"); const [slippage, setSlippage] = useState("1");
const [executor, setExecutor] = useState<UniswapToUniswapExecutor | null>( const [executor, setExecutor] = useState<UniswapToUniswapExecutor | null>(
@ -150,18 +165,25 @@ export default function Home() {
const [hasQuote, setHasQuote] = useState(false); const [hasQuote, setHasQuote] = useState(false);
const { provider, signer } = useEthereumProvider(); const { provider, signer } = useEthereumProvider();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [isFirstSwapComplete, setIsFirstSwapComplete] = useState(false);
const [isSecondSwapComplete, setIsSecondSwapComplete] = useState(false);
const [sourceTxBlockNumber, setSourceTxBlockNumber] = useState<
number | undefined
>(undefined);
const [hasSignedVAA, setHasSignedVAA] = useState(false);
const [relayerTimeoutString, setRelayerTimeoutString] = useState("");
const computeQuote = useCallback(() => { const computeQuote = useCallback(() => {
(async () => { (async () => {
setHasQuote(false); setHasQuote(false);
setIsComputingQuote(true); setIsComputingQuote(true);
setAmountOut("");
try { try {
if ( if (
parseFloat(amountIn) > 0 && parseFloat(amountIn) > 0 &&
!isNaN(parseFloat(deadline)) && !isNaN(parseFloat(deadline)) &&
!isNaN(parseFloat(slippage)) !isNaN(parseFloat(slippage))
) { ) {
setAmountOut("0.0");
const executor = new UniswapToUniswapExecutor(); const executor = new UniswapToUniswapExecutor();
await executor.initialize( await executor.initialize(
sourceTokenInfo.address, sourceTokenInfo.address,
@ -184,9 +206,12 @@ export default function Home() {
executor.tokens.dstOut.formatAmount(quote.dst.minAmountOut) executor.tokens.dstOut.formatAmount(quote.dst.minAmountOut)
).toFixed(8) ).toFixed(8)
); );
setAmountInUST(
parseFloat(
executor.tokens.dstIn.formatAmount(quote.dst.amountIn)
).toFixed(2)
);
setHasQuote(true); setHasQuote(true);
} else {
setAmountOut("0.0");
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -246,13 +271,28 @@ export default function Home() {
setTargetTokenInfo(ETH_TOKEN_INFO); setTargetTokenInfo(ETH_TOKEN_INFO);
} }
setAmountIn(""); setAmountIn("");
setAmountOut("0.0"); setAmountOut("");
}, []);
const reset = useCallback(() => {
setIsSwapping(false);
setHasQuote(false);
setIsFirstSwapComplete(false);
setIsSecondSwapComplete(false);
setAmountIn("");
setAmountOut("");
setSourceTxBlockNumber(undefined);
setRelayerTimeoutString("");
}, []); }, []);
const handleSwapClick = useCallback(async () => { const handleSwapClick = useCallback(async () => {
if (provider && signer && executor) { if (provider && signer && executor) {
try { try {
setIsSwapping(true); setIsSwapping(true);
setIsFirstSwapComplete(false);
setHasSignedVAA(false);
setIsSecondSwapComplete(false);
setRelayerTimeoutString("");
await switchProviderNetwork(provider, sourceTokenInfo.chainId); await switchProviderNetwork(provider, sourceTokenInfo.chainId);
const sourceReceipt = await executor.approveAndSwap(signer); const sourceReceipt = await executor.approveAndSwap(signer);
@ -260,24 +300,17 @@ export default function Home() {
"firstSwapTransactionHash:", "firstSwapTransactionHash:",
sourceReceipt.transactionHash sourceReceipt.transactionHash
); );
setIsFirstSwapComplete(true);
setSourceTxBlockNumber(sourceReceipt.blockNumber);
// Wait for the guardian network to reach consensus and emit the signedVAA // Wait for the guardian network to reach consensus and emit the signedVAA
enqueueSnackbar(null, {
content: <Alert severity="info">Fetching VAA</Alert>,
});
const { vaaBytes } = await getSignedVAAWithRetry( const { vaaBytes } = await getSignedVAAWithRetry(
WORMHOLE_RPC_HOSTS, WORMHOLE_RPC_HOSTS,
executor.srcExecutionParams.wormhole.chainId, executor.srcExecutionParams.wormhole.chainId,
executor.vaaSearchParams.emitterAddress, executor.vaaSearchParams.emitterAddress,
executor.vaaSearchParams.sequence executor.vaaSearchParams.sequence
); );
enqueueSnackbar(null, { setHasSignedVAA(true);
content: (
<Alert severity="info">
Fetched the Signed VAA, waiting for relayer to redeem it
</Alert>
),
});
// Check if the signedVAA has redeemed by the relayer // Check if the signedVAA has redeemed by the relayer
const isCompleted = await getIsTransferCompletedEvmWithRetry( const isCompleted = await getIsTransferCompletedEvmWithRetry(
executor.dstExecutionParams.wormhole.tokenBridgeAddress, executor.dstExecutionParams.wormhole.tokenBridgeAddress,
@ -287,30 +320,26 @@ export default function Home() {
3000, 3000,
40 40
); );
if (isCompleted) { if (!isCompleted) {
enqueueSnackbar(null, {
content: <Alert severity="success">Swap completed</Alert>,
});
} else {
// If the relayer hasn't redeemed the signedVAA, then manually redeem it ourselves // If the relayer hasn't redeemed the signedVAA, then manually redeem it ourselves
setRelayerTimeoutString(
"Timed out waiting for relayer to complete swap. You'll need to complete it yourself."
);
await switchProviderNetwork(provider, targetTokenInfo.chainId); await switchProviderNetwork(provider, targetTokenInfo.chainId);
const targetReceipt = await executor.fetchVaaAndSwap(signer); const targetReceipt = await executor.fetchVaaAndSwap(signer);
enqueueSnackbar(null, {
content: <Alert severity="success">Swap completed</Alert>,
});
console.info( console.info(
"secondSwapTransactionHash:", "secondSwapTransactionHash:",
targetReceipt.transactionHash targetReceipt.transactionHash
); );
} }
setIsSecondSwapComplete(true);
} catch (e: any) { } catch (e: any) {
reset();
console.error(e); console.error(e);
enqueueSnackbar(null, { enqueueSnackbar(null, {
content: <Alert severity="error">{parseError(e)}</Alert>, content: <Alert severity="error">{parseError(e)}</Alert>,
}); });
} }
setIsSwapping(false);
setAmountIn("");
} }
}, [ }, [
provider, provider,
@ -319,6 +348,7 @@ export default function Home() {
enqueueSnackbar, enqueueSnackbar,
sourceTokenInfo, sourceTokenInfo,
targetTokenInfo, targetTokenInfo,
reset,
]); ]);
const readyToSwap = provider && signer && hasQuote; const readyToSwap = provider && signer && hasQuote;
@ -328,58 +358,103 @@ export default function Home() {
<Container className={classes.centeredContainer} maxWidth="sm"> <Container className={classes.centeredContainer} maxWidth="sm">
<div className={classes.titleBar}></div> <div className={classes.titleBar}></div>
<Typography variant="h4" color="textSecondary"> <Typography variant="h4" color="textSecondary">
Cross Chain Swap Demo Wormhole NativeSwap Demo
</Typography> </Typography>
<div className={classes.spacer} /> <div className={classes.spacer} />
<Paper className={classes.mainPaper}> <Paper className={classes.mainPaper}>
<Settings <Collapse in={!isFirstSwapComplete}>
disabled={isSwapping || isComputingQuote} <Settings
slippage={slippage} disabled={isSwapping || isComputingQuote}
deadline={deadline} slippage={slippage}
onSlippageChange={handleSlippageChange} deadline={deadline}
onDeadlineChange={handleDeadlineChange} onSlippageChange={handleSlippageChange}
/> onDeadlineChange={handleDeadlineChange}
<TokenSelect />
tokens={TOKEN_INFOS} <TokenSelect
value={sourceTokenInfo.name} tokens={TOKEN_INFOS}
onChange={handleSourceChange} value={sourceTokenInfo.name}
disabled={isSwapping || isComputingQuote} onChange={handleSourceChange}
></TokenSelect> disabled={isSwapping || isComputingQuote}
<Typography variant="subtitle1">Send</Typography> ></TokenSelect>
<TextField <Typography variant="subtitle1">Send</Typography>
type="number" <TextField
value={amountIn} type="number"
disabled={isSwapping || isComputingQuote} value={amountIn}
InputProps={{ disableUnderline: true }} disabled={isSwapping || isComputingQuote}
className={classes.numberField} InputProps={{ disableUnderline: true }}
onChange={handleAmountChange} className={classes.numberField}
placeholder="0.0" onChange={handleAmountChange}
></TextField> placeholder="0.0"
></TextField>
<div className={classes.spacer} />
<TokenSelect
tokens={TOKEN_INFOS}
value={targetTokenInfo.name}
onChange={() => {}}
disabled={true}
></TokenSelect>
<Typography variant="subtitle1">Receive (estimated)</Typography>
<TextField
type="number"
value={amountOut}
autoFocus={true}
InputProps={{ disableUnderline: true }}
className={classes.numberField}
inputProps={{ readOnly: true }}
placeholder="0.0"
></TextField>
<Typography variant="subtitle2">{`Slippage tolerance: ${slippage}%`}</Typography>
{!isSwapping && <EthereumSignerKey />}
<ButtonWithLoader
disabled={!readyToSwap || isSwapping}
showLoader={isSwapping}
onClick={handleSwapClick}
>
Swap
</ButtonWithLoader>
</Collapse>
<Collapse in={isFirstSwapComplete && !isSecondSwapComplete}>
<div className={classes.loaderHolder}>
<CircleLoader />
<div className={classes.spacer} />
<Typography variant="h5">
{`Your ${sourceTokenInfo.name} is being swapped to ${targetTokenInfo.name}`}
</Typography>
</div>
</Collapse>
<Collapse in={isSecondSwapComplete}>
<div className={classes.loaderHolder}>
<CheckCircleOutlineRounded
className={classes.successIcon}
fontSize={"inherit"}
/>
<Typography>Swap completed!</Typography>
<ButtonWithLoader onClick={() => reset()}>
Swap more tokens!
</ButtonWithLoader>
</div>
</Collapse>
<div className={classes.spacer} /> <div className={classes.spacer} />
<TokenSelect {hasQuote && (
tokens={TOKEN_INFOS} <Typography variant="subtitle1">
value={targetTokenInfo.name} {`${amountIn} ${sourceTokenInfo.name} `}
onChange={() => {}} <ArrowForward fontSize="inherit" />
disabled={true} {` ${amountInUST} UST `} <ArrowForward fontSize="inherit" />
></TokenSelect> {` ${amountOut} ${targetTokenInfo.name}`}
<Typography variant="subtitle1">Receive (estimated)</Typography> </Typography>
<TextField )}
type="number" {isFirstSwapComplete &&
value={amountOut} !isSecondSwapComplete &&
autoFocus={true} !relayerTimeoutString && (
InputProps={{ disableUnderline: true }} <SwapProgress
className={classes.numberField} chainId={sourceTokenInfo.chainId}
inputProps={{ readOnly: true }} txBlockNumber={sourceTxBlockNumber}
></TextField> step={!hasSignedVAA ? 1 : !isSecondSwapComplete ? 2 : 3}
<Typography variant="subtitle2">{`Slippage tolerance: ${slippage}%`}</Typography> />
{!isSwapping && <EthereumSignerKey />} )}
<ButtonWithLoader {relayerTimeoutString && (
disabled={!readyToSwap || isSwapping} <Typography variant="subtitle1">{relayerTimeoutString}</Typography>
showLoader={isSwapping} )}
onClick={handleSwapClick}
>
Swap
</ButtonWithLoader>
<div className={classes.spacer} /> <div className={classes.spacer} />
<Typography variant="subtitle2" color="error"> <Typography variant="subtitle2" color="error">
WARNING: this is a Testnet release only WARNING: this is a Testnet release only