diff --git a/contracts/.gitignore b/contracts/.gitignore index 4c49bd7..79b531a 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -1 +1,3 @@ .env +build/ +node_modules/ diff --git a/react/package.json b/react/package.json index 8ec29e4..6e094f4 100644 --- a/react/package.json +++ b/react/package.json @@ -1,5 +1,5 @@ { - "name": "Cross Chain Swap", + "name": "NativeSwap", "version": "0.1.0", "private": true, "dependencies": { diff --git a/react/public/index.html b/react/public/index.html index 149fdf7..a3f3ac9 100644 --- a/react/public/index.html +++ b/react/public/index.html @@ -24,7 +24,7 @@ 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`. --> - Cross Chain Swap + NativeSwap diff --git a/react/src/components/CircleLoader.tsx b/react/src/components/CircleLoader.tsx new file mode 100644 index 0000000..9d3a179 --- /dev/null +++ b/react/src/components/CircleLoader.tsx @@ -0,0 +1,11 @@ +import "../css/CircleLoader.css"; + +export default function CircleLoader() { + return ( +
+
+
+
+
+ ); +} diff --git a/react/src/components/SwapProgress.tsx b/react/src/components/SwapProgress.tsx new file mode 100644 index 0000000..8087fab --- /dev/null +++ b/react/src/components/SwapProgress.tsx @@ -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 ( +
+ + + {message} + +
+ ); +} diff --git a/react/src/css/CircleLoader.css b/react/src/css/CircleLoader.css new file mode 100644 index 0000000..eb11e05 --- /dev/null +++ b/react/src/css/CircleLoader.css @@ -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; +} diff --git a/react/src/utils/getIsTransferCompletedWithRetry.ts b/react/src/utils/getIsTransferCompletedWithRetry.ts index 39463a8..cae60f1 100644 --- a/react/src/utils/getIsTransferCompletedWithRetry.ts +++ b/react/src/utils/getIsTransferCompletedWithRetry.ts @@ -17,7 +17,6 @@ export default async function getIsTransferCompletedEvmWithRetry( provider, signedVAA ); - console.log("getIsTransferCompletedEth", result); } catch (e) { console.error(e); } diff --git a/react/src/views/Home.tsx b/react/src/views/Home.tsx index cac2ca8..f56f646 100644 --- a/react/src/views/Home.tsx +++ b/react/src/views/Home.tsx @@ -1,4 +1,5 @@ import { + Collapse, Container, Link, makeStyles, @@ -33,6 +34,9 @@ import { Alert } from "@material-ui/lab"; import parseError from "../utils/parseError"; import Settings from "../components/Settings"; 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) => ({ bg: { @@ -115,6 +119,16 @@ const useStyles = makeStyles((theme) => ({ margin: "1rem", display: "inline-block", }, + loaderHolder: { + display: "flex", + justifyContent: "center", + flexDirection: "column", + alignItems: "center", + }, + successIcon: { + color: COLORS.green, + fontSize: "200px", + }, })); const switchProviderNetwork = async ( @@ -136,10 +150,11 @@ const switchProviderNetwork = async ( export default function Home() { const classes = useStyles(); - const [sourceTokenInfo, setSourceTokenInfo] = useState(WMATIC_TOKEN_INFO); - const [targetTokenInfo, setTargetTokenInfo] = useState(WETH_TOKEN_INFO); + const [sourceTokenInfo, setSourceTokenInfo] = useState(MATIC_TOKEN_INFO); + const [targetTokenInfo, setTargetTokenInfo] = useState(ETH_TOKEN_INFO); const [amountIn, setAmountIn] = useState(""); - const [amountOut, setAmountOut] = useState("0.0"); + const [amountInUST, setAmountInUST] = useState(""); + const [amountOut, setAmountOut] = useState(""); const [deadline, setDeadline] = useState("30"); const [slippage, setSlippage] = useState("1"); const [executor, setExecutor] = useState( @@ -150,18 +165,25 @@ export default function Home() { const [hasQuote, setHasQuote] = useState(false); const { provider, signer } = useEthereumProvider(); 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(() => { (async () => { setHasQuote(false); setIsComputingQuote(true); + setAmountOut(""); try { if ( parseFloat(amountIn) > 0 && !isNaN(parseFloat(deadline)) && !isNaN(parseFloat(slippage)) ) { - setAmountOut("0.0"); const executor = new UniswapToUniswapExecutor(); await executor.initialize( sourceTokenInfo.address, @@ -184,9 +206,12 @@ export default function Home() { executor.tokens.dstOut.formatAmount(quote.dst.minAmountOut) ).toFixed(8) ); + setAmountInUST( + parseFloat( + executor.tokens.dstIn.formatAmount(quote.dst.amountIn) + ).toFixed(2) + ); setHasQuote(true); - } else { - setAmountOut("0.0"); } } catch (e) { console.error(e); @@ -246,13 +271,28 @@ export default function Home() { setTargetTokenInfo(ETH_TOKEN_INFO); } 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 () => { if (provider && signer && executor) { try { setIsSwapping(true); + setIsFirstSwapComplete(false); + setHasSignedVAA(false); + setIsSecondSwapComplete(false); + setRelayerTimeoutString(""); await switchProviderNetwork(provider, sourceTokenInfo.chainId); const sourceReceipt = await executor.approveAndSwap(signer); @@ -260,24 +300,17 @@ export default function Home() { "firstSwapTransactionHash:", sourceReceipt.transactionHash ); + setIsFirstSwapComplete(true); + setSourceTxBlockNumber(sourceReceipt.blockNumber); // Wait for the guardian network to reach consensus and emit the signedVAA - enqueueSnackbar(null, { - content: Fetching VAA, - }); const { vaaBytes } = await getSignedVAAWithRetry( WORMHOLE_RPC_HOSTS, executor.srcExecutionParams.wormhole.chainId, executor.vaaSearchParams.emitterAddress, executor.vaaSearchParams.sequence ); - enqueueSnackbar(null, { - content: ( - - Fetched the Signed VAA, waiting for relayer to redeem it - - ), - }); + setHasSignedVAA(true); // Check if the signedVAA has redeemed by the relayer const isCompleted = await getIsTransferCompletedEvmWithRetry( executor.dstExecutionParams.wormhole.tokenBridgeAddress, @@ -287,30 +320,26 @@ export default function Home() { 3000, 40 ); - if (isCompleted) { - enqueueSnackbar(null, { - content: Swap completed, - }); - } else { + if (!isCompleted) { // 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); const targetReceipt = await executor.fetchVaaAndSwap(signer); - enqueueSnackbar(null, { - content: Swap completed, - }); console.info( "secondSwapTransactionHash:", targetReceipt.transactionHash ); } + setIsSecondSwapComplete(true); } catch (e: any) { + reset(); console.error(e); enqueueSnackbar(null, { content: {parseError(e)}, }); } - setIsSwapping(false); - setAmountIn(""); } }, [ provider, @@ -319,6 +348,7 @@ export default function Home() { enqueueSnackbar, sourceTokenInfo, targetTokenInfo, + reset, ]); const readyToSwap = provider && signer && hasQuote; @@ -328,58 +358,103 @@ export default function Home() {
- Cross Chain Swap Demo + Wormhole NativeSwap Demo
- - - Send - + + + + Send + +
+ {}} + disabled={true} + > + Receive (estimated) + + {`Slippage tolerance: ${slippage}%`} + {!isSwapping && } + + Swap + + + +
+ +
+ + {`Your ${sourceTokenInfo.name} is being swapped to ${targetTokenInfo.name}`} + +
+ + +
+ + Swap completed! + reset()}> + Swap more tokens! + +
+
- {}} - disabled={true} - > - Receive (estimated) - - {`Slippage tolerance: ${slippage}%`} - {!isSwapping && } - - Swap - + {hasQuote && ( + + {`${amountIn} ${sourceTokenInfo.name} `} + + {` ${amountInUST} UST `} + {` ${amountOut} ${targetTokenInfo.name}`} + + )} + {isFirstSwapComplete && + !isSecondSwapComplete && + !relayerTimeoutString && ( + + )} + {relayerTimeoutString && ( + {relayerTimeoutString} + )}
WARNING: this is a Testnet release only