From d8a8d5722ab823e315c7b09292bc76a0bef523bc Mon Sep 17 00:00:00 2001 From: Chase Moran Date: Tue, 28 Sep 2021 23:23:51 -0400 Subject: [PATCH] lp_ui: ethereum migration pool functionality Change-Id: Ibdf24e1f90e711e5284016045c0c7d9d413be4ac --- lp_ui/package-lock.json | 18 +- lp_ui/package.json | 2 + lp_ui/src/App.js | 4 +- lp_ui/src/components/EthereumSignerKey.tsx | 25 + lp_ui/src/components/LogWatcher.tsx | 7 +- lp_ui/src/components/SmartAddress.tsx | 151 ++++++ .../src/components/ToggleConnectedButton.tsx | 51 +++ .../src/contexts/EthereumProviderContext.tsx | 158 +++++++ lp_ui/src/contexts/Logger.tsx | 2 +- lp_ui/src/hooks/useCopyToClipboard.ts | 12 + .../hooks/useEthereumMigratorInformation.tsx | 168 +++++++ lp_ui/src/index.js | 13 +- lp_ui/src/utils/pushToClipboard.ts | 7 + lp_ui/src/views/DeployNewEthereum.tsx | 116 +++++ lp_ui/src/views/Home.tsx | 104 +++++ lp_ui/src/views/Main.tsx | 20 +- lp_ui/src/views/MigrateEthereum.tsx | 431 ++++++++++++++++++ 17 files changed, 1257 insertions(+), 32 deletions(-) create mode 100644 lp_ui/src/components/EthereumSignerKey.tsx create mode 100644 lp_ui/src/components/SmartAddress.tsx create mode 100644 lp_ui/src/components/ToggleConnectedButton.tsx create mode 100644 lp_ui/src/contexts/EthereumProviderContext.tsx create mode 100644 lp_ui/src/hooks/useCopyToClipboard.ts create mode 100644 lp_ui/src/hooks/useEthereumMigratorInformation.tsx create mode 100644 lp_ui/src/utils/pushToClipboard.ts create mode 100644 lp_ui/src/views/DeployNewEthereum.tsx create mode 100644 lp_ui/src/views/Home.tsx create mode 100644 lp_ui/src/views/MigrateEthereum.tsx diff --git a/lp_ui/package-lock.json b/lp_ui/package-lock.json index 309d2a2c..c857946c 100644 --- a/lp_ui/package-lock.json +++ b/lp_ui/package-lock.json @@ -7,12 +7,12 @@ "": { "name": "lp_ui", "version": "0.1.0", - "hasInstallScript": true, "dependencies": { "@certusone/wormhole-sdk": "file:..\\sdk\\js", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", + "@metamask/detect-provider": "^1.2.0", "@solana/spl-token": "^0.1.6", "@solana/spl-token-registry": "^0.2.216", "@solana/wallet-adapter-base": "^0.5.2", @@ -25,6 +25,7 @@ "@types/node": "^16.9.1", "@types/react": "^17.0.20", "@types/react-dom": "^17.0.9", + "clsx": "^1.1.1", "ethers": "^5.4.6", "notistack": "^1.0.10", "react": "^17.0.2", @@ -39,7 +40,7 @@ }, "../sdk/js": { "name": "@certusone/wormhole-sdk", - "version": "0.0.2", + "version": "0.0.5", "license": "Apache-2.0", "dependencies": { "@improbable-eng/grpc-web": "^0.14.0", @@ -3542,6 +3543,14 @@ "react-dom": "^16.8.0 || ^17.0.0" } }, + "node_modules/@metamask/detect-provider": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@metamask/detect-provider/-/detect-provider-1.2.0.tgz", + "integrity": "sha512-ocA76vt+8D0thgXZ7LxFPyqw3H7988qblgzddTDA6B8a/yU0uKV42QR/DhA+Jh11rJjxW0jKvwb5htA6krNZDQ==", + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -26318,6 +26327,11 @@ "react-is": "^16.8.0 || ^17.0.0" } }, + "@metamask/detect-provider": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@metamask/detect-provider/-/detect-provider-1.2.0.tgz", + "integrity": "sha512-ocA76vt+8D0thgXZ7LxFPyqw3H7988qblgzddTDA6B8a/yU0uKV42QR/DhA+Jh11rJjxW0jKvwb5htA6krNZDQ==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/lp_ui/package.json b/lp_ui/package.json index 53d721a8..cc9b0987 100644 --- a/lp_ui/package.json +++ b/lp_ui/package.json @@ -7,6 +7,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", + "@metamask/detect-provider": "^1.2.0", "@solana/spl-token": "^0.1.6", "@solana/spl-token-registry": "^0.2.216", "@solana/wallet-adapter-base": "^0.5.2", @@ -19,6 +20,7 @@ "@types/node": "^16.9.1", "@types/react": "^17.0.20", "@types/react-dom": "^17.0.9", + "clsx": "^1.1.1", "ethers": "^5.4.6", "notistack": "^1.0.10", "react": "^17.0.2", diff --git a/lp_ui/src/App.js b/lp_ui/src/App.js index bc265c19..5515428f 100644 --- a/lp_ui/src/App.js +++ b/lp_ui/src/App.js @@ -1,7 +1,7 @@ -import Main from "./views/Main"; +import Home from "./views/Home"; function App() { - return
; + return ; } export default App; diff --git a/lp_ui/src/components/EthereumSignerKey.tsx b/lp_ui/src/components/EthereumSignerKey.tsx new file mode 100644 index 00000000..f77c8006 --- /dev/null +++ b/lp_ui/src/components/EthereumSignerKey.tsx @@ -0,0 +1,25 @@ +import { Typography } from "@material-ui/core"; +import { useEthereumProvider } from "../contexts/EthereumProviderContext"; +import ToggleConnectedButton from "./ToggleConnectedButton"; + +const EthereumSignerKey = () => { + const { connect, disconnect, signerAddress, providerError } = + useEthereumProvider(); + return ( + <> + + {providerError ? ( + + {providerError} + + ) : null} + + ); +}; + +export default EthereumSignerKey; diff --git a/lp_ui/src/components/LogWatcher.tsx b/lp_ui/src/components/LogWatcher.tsx index 110fa780..36a64673 100644 --- a/lp_ui/src/components/LogWatcher.tsx +++ b/lp_ui/src/components/LogWatcher.tsx @@ -1,13 +1,8 @@ import { Button, Paper, Typography } from "@material-ui/core"; -import { useEffect } from "react"; import { useLogger } from "../contexts/Logger"; function LogWatcher() { - const { logs, clear, log } = useLogger(); - - useEffect(() => { - log("Instantiated the logger."); - }, [log]); + const { logs, clear } = useLogger(); return ( diff --git a/lp_ui/src/components/SmartAddress.tsx b/lp_ui/src/components/SmartAddress.tsx new file mode 100644 index 00000000..41a2cde1 --- /dev/null +++ b/lp_ui/src/components/SmartAddress.tsx @@ -0,0 +1,151 @@ +import { + ChainId, + CHAIN_ID_ETH, + CHAIN_ID_SOLANA, +} from "@certusone/wormhole-sdk"; +import { Button, makeStyles, Tooltip, Typography } from "@material-ui/core"; +import { FileCopy, OpenInNew } from "@material-ui/icons"; +import { withStyles } from "@material-ui/styles"; +import clsx from "clsx"; +import useCopyToClipboard from "../hooks/useCopyToClipboard"; +import { CLUSTER } from "../utils/consts"; +import { shortenAddress } from "../utils/solana"; + +const useStyles = makeStyles((theme) => ({ + mainTypog: { + display: "inline-block", + marginLeft: theme.spacing(1), + marginRight: theme.spacing(1), + textDecoration: "underline", + textUnderlineOffset: "2px", + }, + noGutter: { + marginLeft: 0, + marginRight: 0, + }, + noUnderline: { + textDecoration: "none", + }, + buttons: { + marginLeft: ".5rem", + marginRight: ".5rem", + }, +})); + +const tooltipStyles = { + tooltip: { + minWidth: "max-content", + textAlign: "center", + "& > *": { + margin: ".25rem", + }, + }, +}; + +// @ts-ignore +const StyledTooltip = withStyles(tooltipStyles)(Tooltip); + +export default function SmartAddress({ + chainId, + address, + symbol, + tokenName, + variant, + noGutter, + noUnderline, +}: { + chainId: ChainId; + address?: string; + logo?: string; + tokenName?: string; + symbol?: string; + variant?: any; + noGutter?: boolean; + noUnderline?: boolean; +}) { + const classes = useStyles(); + const useableAddress = address || ""; + const useableSymbol = symbol || ""; + const isNative = false; + const addressShort = shortenAddress(useableAddress) || ""; + + const useableName = tokenName || ""; + //TODO terra + const explorerAddress = isNative + ? null + : chainId === CHAIN_ID_ETH + ? `https://${ + CLUSTER === "testnet" ? "goerli." : "" + }etherscan.io/address/${useableAddress}` + : chainId === CHAIN_ID_SOLANA + ? `https://explorer.solana.com/address/${useableAddress}${ + CLUSTER === "testnet" + ? "?cluster=testnet" + : CLUSTER === "devnet" + ? "?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899" + : "" + }` + : undefined; + const explorerName = chainId === CHAIN_ID_ETH ? "Etherscan" : "Explorer"; + + const copyToClipboard = useCopyToClipboard(useableAddress); + + const explorerButton = !explorerAddress ? null : ( + + ); + //TODO add icon here + const copyButton = isNative ? null : ( + + ); + + const tooltipContent = ( + <> + {useableName && {useableName}} + {useableSymbol && !isNative && ( + + {addressShort} + + )} +
+ {explorerButton} + {copyButton} +
+ + ); + + return ( + + + {useableSymbol || addressShort} + + + ); +} diff --git a/lp_ui/src/components/ToggleConnectedButton.tsx b/lp_ui/src/components/ToggleConnectedButton.tsx new file mode 100644 index 00000000..529ab278 --- /dev/null +++ b/lp_ui/src/components/ToggleConnectedButton.tsx @@ -0,0 +1,51 @@ +import { Button, makeStyles, Tooltip } from "@material-ui/core"; + +const useStyles = makeStyles((theme) => ({ + button: { + display: "block", + margin: `${theme.spacing(1)}px auto`, + width: "100%", + maxWidth: 400, + }, +})); + +const ToggleConnectedButton = ({ + connect, + disconnect, + connected, + pk, +}: { + connect(): any; + disconnect(): any; + connected: boolean; + pk: string; +}) => { + const classes = useStyles(); + const is0x = pk.startsWith("0x"); + return connected ? ( + + + + ) : ( + + ); +}; + +export default ToggleConnectedButton; diff --git a/lp_ui/src/contexts/EthereumProviderContext.tsx b/lp_ui/src/contexts/EthereumProviderContext.tsx new file mode 100644 index 00000000..4f3dfb52 --- /dev/null +++ b/lp_ui/src/contexts/EthereumProviderContext.tsx @@ -0,0 +1,158 @@ +import detectEthereumProvider from "@metamask/detect-provider"; +import { BigNumber, ethers } from "ethers"; +import React, { + ReactChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +export type Provider = ethers.providers.Web3Provider | undefined; +export type Signer = ethers.Signer | undefined; + +interface IEthereumProviderContext { + connect(): void; + disconnect(): void; + provider: Provider; + chainId: number | undefined; + signer: Signer; + signerAddress: string | undefined; + providerError: string | null; +} + +const EthereumProviderContext = React.createContext({ + connect: () => {}, + disconnect: () => {}, + provider: undefined, + chainId: undefined, + signer: undefined, + signerAddress: undefined, + providerError: null, +}); +export const EthereumProviderProvider = ({ + children, +}: { + children: ReactChildren; +}) => { + const [providerError, setProviderError] = useState(null); + const [provider, setProvider] = useState(undefined); + const [chainId, setChainId] = useState(undefined); + const [signer, setSigner] = useState(undefined); + const [signerAddress, setSignerAddress] = useState( + undefined + ); + const connect = useCallback(() => { + setProviderError(null); + detectEthereumProvider() + .then((detectedProvider) => { + if (detectedProvider) { + const provider = new ethers.providers.Web3Provider( + // @ts-ignore + detectedProvider, + "any" + ); + provider + .send("eth_requestAccounts", []) + .then(() => { + setProviderError(null); + setProvider(provider); + provider + .getNetwork() + .then((network) => { + setChainId(network.chainId); + }) + .catch(() => { + setProviderError( + "An error occurred while getting the network" + ); + }); + const signer = provider.getSigner(); + setSigner(signer); + signer + .getAddress() + .then((address) => { + setSignerAddress(address); + }) + .catch(() => { + setProviderError( + "An error occurred while getting the signer address" + ); + }); + // TODO: try using ethers directly + // @ts-ignore + if (detectedProvider && detectedProvider.on) { + // @ts-ignore + detectedProvider.on("chainChanged", (chainId) => { + try { + setChainId(BigNumber.from(chainId).toNumber()); + } catch (e) {} + }); + // @ts-ignore + detectedProvider.on("accountsChanged", (accounts) => { + try { + const signer = provider.getSigner(); + setSigner(signer); + signer + .getAddress() + .then((address) => { + setSignerAddress(address); + }) + .catch(() => { + setProviderError( + "An error occurred while getting the signer address" + ); + }); + } catch (e) {} + }); + } + }) + .catch(() => { + setProviderError( + "An error occurred while requesting eth accounts" + ); + }); + } else { + setProviderError("Please install MetaMask"); + } + }) + .catch(() => { + setProviderError("Please install MetaMask"); + }); + }, []); + const disconnect = useCallback(() => { + setProviderError(null); + setProvider(undefined); + setChainId(undefined); + setSigner(undefined); + setSignerAddress(undefined); + }, []); + const contextValue = useMemo( + () => ({ + connect, + disconnect, + provider, + chainId, + signer, + signerAddress, + providerError, + }), + [ + connect, + disconnect, + provider, + chainId, + signer, + signerAddress, + providerError, + ] + ); + return ( + + {children} + + ); +}; +export const useEthereumProvider = () => { + return useContext(EthereumProviderContext); +}; diff --git a/lp_ui/src/contexts/Logger.tsx b/lp_ui/src/contexts/Logger.tsx index 6595c616..895aeafe 100644 --- a/lp_ui/src/contexts/Logger.tsx +++ b/lp_ui/src/contexts/Logger.tsx @@ -20,7 +20,7 @@ const LoggerProviderContext = React.createContext({ }); export const LoggerProvider = ({ children }: { children: ReactChildren }) => { - const [logs, setLogs] = useState([]); + const [logs, setLogs] = useState(["Instantiated the logger."]); const clear = useCallback(() => setLogs([]), [setLogs]); const { enqueueSnackbar } = useSnackbar(); diff --git a/lp_ui/src/hooks/useCopyToClipboard.ts b/lp_ui/src/hooks/useCopyToClipboard.ts new file mode 100644 index 00000000..6d4d0fb5 --- /dev/null +++ b/lp_ui/src/hooks/useCopyToClipboard.ts @@ -0,0 +1,12 @@ +import { useSnackbar } from "notistack"; +import { useCallback } from "react"; +import pushToClipboard from "../utils/pushToClipboard"; + +export default function useCopyToClipboard(content: string) { + const { enqueueSnackbar } = useSnackbar(); + return useCallback(() => { + pushToClipboard(content)?.then(() => { + enqueueSnackbar("Copied", { variant: "success" }); + }); + }, [content, enqueueSnackbar]); +} diff --git a/lp_ui/src/hooks/useEthereumMigratorInformation.tsx b/lp_ui/src/hooks/useEthereumMigratorInformation.tsx new file mode 100644 index 00000000..b5966675 --- /dev/null +++ b/lp_ui/src/hooks/useEthereumMigratorInformation.tsx @@ -0,0 +1,168 @@ +import { + Migrator, + Migrator__factory, + TokenImplementation, + TokenImplementation__factory, +} from "@certusone/wormhole-sdk"; +import { Signer } from "@ethersproject/abstract-signer"; +import { formatUnits } from "@ethersproject/units"; +import { useEffect, useMemo, useState } from "react"; + +export type EthMigrationInfo = { + isLoading: boolean; + error: string; + data: RequisiteData | null; +}; + +export type RequisiteData = { + poolAddress: string; + fromAddress: string; + toAddress: string; + fromToken: TokenImplementation; + toToken: TokenImplementation; + migrator: Migrator; + fromSymbol: string; + toSymbol: string; + fromDecimals: number; + toDecimals: number; + sharesDecimals: number; + fromWalletBalance: string; + toWalletBalance: string; + fromPoolBalance: string; + toPoolBalance: string; + walletSharesBalance: string; +}; + +const getRequisiteData = async ( + migrator: Migrator, + signer: Signer, + signerAddress: string +): Promise => { + try { + const poolAddress = migrator.address; + const fromAddress = await migrator.fromAsset(); + const toAddress = await migrator.toAsset(); + + const fromToken = TokenImplementation__factory.connect(fromAddress, signer); + const toToken = TokenImplementation__factory.connect(toAddress, signer); + + const fromSymbol = await fromToken.symbol(); + const toSymbol = await toToken.symbol(); + + const fromDecimals = await (await migrator.fromDecimals()).toNumber(); + const toDecimals = await (await migrator.toDecimals()).toNumber(); + const sharesDecimals = await migrator.decimals(); + + const fromWalletBalance = formatUnits( + await fromToken.balanceOf(signerAddress), + fromDecimals + ); + const toWalletBalance = formatUnits( + await toToken.balanceOf(signerAddress), + toDecimals + ); + + const fromPoolBalance = formatUnits( + await fromToken.balanceOf(poolAddress), + fromDecimals + ); + const toPoolBalance = formatUnits( + await toToken.balanceOf(poolAddress), + toDecimals + ); + + const walletSharesBalance = formatUnits( + await migrator.balanceOf(signerAddress), + sharesDecimals + ); + + return { + poolAddress, + fromAddress, + toAddress, + fromToken, + toToken, + migrator, + fromSymbol, + toSymbol, + fromDecimals, + toDecimals, + fromWalletBalance, + toWalletBalance, + fromPoolBalance, + toPoolBalance, + walletSharesBalance, + sharesDecimals, + }; + } catch (e) { + return Promise.reject("Failed to retrieve required data."); + } +}; + +function useEthereumMigratorInformation( + migratorAddress: string | undefined, + signer: Signer | undefined, + signerAddress: string | undefined, + toggleRefresh: boolean +): EthMigrationInfo { + const migrator = useMemo( + () => + migratorAddress && + signer && + Migrator__factory.connect(migratorAddress, signer), + [migratorAddress, signer] + ); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!signer || !migrator || !signerAddress) { + return; + } + let cancelled = false; + setIsLoading(true); + getRequisiteData(migrator, signer, signerAddress).then( + (result) => { + if (!cancelled) { + setData(result); + setIsLoading(false); + } + }, + (error) => { + if (!cancelled) { + setIsLoading(false); + setError("Failed to retrieve necessary data."); + } + } + ); + + return () => { + cancelled = true; + return; + }; + }, [migrator, signer, signerAddress, toggleRefresh]); + + return useMemo(() => { + if (!migratorAddress || !signer || !signerAddress) { + return { + isLoading: false, + error: + !signer || !signerAddress + ? "Wallet not connected" + : !migratorAddress + ? "No contract address" + : "Error", + data: null, + }; + } else { + return { + isLoading, + error, + data, + }; + } + }, [isLoading, error, data, migratorAddress, signer, signerAddress]); +} + +export default useEthereumMigratorInformation; diff --git a/lp_ui/src/index.js b/lp_ui/src/index.js index 260d18bb..fc44001f 100644 --- a/lp_ui/src/index.js +++ b/lp_ui/src/index.js @@ -7,16 +7,19 @@ import { LoggerProvider } from "./contexts/Logger"; import { SolanaWalletProvider } from "./contexts/SolanaWalletContext"; import { theme } from "./muiTheme"; import { SnackbarProvider } from "notistack"; +import { EthereumProviderProvider } from "./contexts/EthereumProviderContext"; ReactDOM.render( - - - - - + + + + + + + , diff --git a/lp_ui/src/utils/pushToClipboard.ts b/lp_ui/src/utils/pushToClipboard.ts new file mode 100644 index 00000000..34351fbf --- /dev/null +++ b/lp_ui/src/utils/pushToClipboard.ts @@ -0,0 +1,7 @@ +export default function pushToClipboard(content: any) { + if (!navigator.clipboard) { + // Clipboard API not available + return; + } + return navigator.clipboard.writeText(content); +} diff --git a/lp_ui/src/views/DeployNewEthereum.tsx b/lp_ui/src/views/DeployNewEthereum.tsx new file mode 100644 index 00000000..cb222159 --- /dev/null +++ b/lp_ui/src/views/DeployNewEthereum.tsx @@ -0,0 +1,116 @@ +import { Migrator__factory } from "@certusone/wormhole-sdk"; +import { + Button, + Container, + makeStyles, + Paper, + TextField, + Typography, +} from "@material-ui/core"; +import { ethers } from "ethers"; +import { useState } from "react"; +import EthereumSignerKey from "../components/EthereumSignerKey"; +import LogWatcher from "../components/LogWatcher"; +import { useEthereumProvider } from "../contexts/EthereumProviderContext"; +import { useLogger } from "../contexts/Logger"; + +const useStyles = makeStyles(() => ({ + rootContainer: {}, + mainPaper: { + "& > *": { + margin: "1rem", + }, + padding: "2rem", + }, + divider: { + margin: "2rem", + }, + spacer: { + height: "1rem", + }, +})); + +function DeployNewEthereum() { + const classes = useStyles(); + const { signer, provider } = useEthereumProvider(); + const { log } = useLogger(); + + const [migratorAddress, setMigratorAddress] = useState(null); + const [error, setError] = useState(null); + const [fromAddress, setFromAddress] = useState(null); + const [toAddress, setToAddress] = useState(null); + + const errorMessage = + error || + (!provider && "Wallet not connected") || + (!fromAddress && "No 'from' address") || + (!toAddress && "No 'to' address"); + + const deployPool = async () => { + if (fromAddress && toAddress) { + const contractInterface = Migrator__factory.createInterface(); + const bytecode = Migrator__factory.bytecode; + const factory = new ethers.ContractFactory( + contractInterface, + bytecode, + signer + ); + const contract = await factory.deploy(fromAddress, toAddress); + contract.deployed().then( + (result) => { + log("Successfully deployed contract at " + result.address); + setMigratorAddress(result.address); + }, + (error) => { + log("Failed to deploy the contract"); + setError((error && error.toString()) || "Unable to create the pool."); + } + ); + } else { + } + }; + + return ( + <> + + + + Create a new Ethereum Liquidity Pool + + + setFromAddress(event.target.value)} + label={"From Token"} + fullWidth + style={{ display: "block" }} + /> + setToAddress(event.target.value)} + label={"To Token"} + fullWidth + style={{ display: "block" }} + /> + + {errorMessage && {errorMessage}} + {migratorAddress !== null && ( + <> + Successfully created a new pool at: + {migratorAddress} + + You may now populate the pool from the Ethereum pool management + page. + + + )} + + + + + ); +} + +export default DeployNewEthereum; diff --git a/lp_ui/src/views/Home.tsx b/lp_ui/src/views/Home.tsx new file mode 100644 index 00000000..d431da0b --- /dev/null +++ b/lp_ui/src/views/Home.tsx @@ -0,0 +1,104 @@ +import { AppBar, Button, Divider, Typography } from "@material-ui/core"; +import { useCallback, useState } from "react"; +import { default as DeployNewEthereum } from "./DeployNewEthereum"; +import MigrateEthereum from "./MigrateEthereum"; +import Main from "./Main"; +import { CLUSTER } from "../utils/consts"; + +const ETH = "Interact with an existing Ethereum pool"; +const NEW_ETH = "Create a New Ethereum Pool"; +const SOL = "Manage Solana Liquidity pools."; + +function Home() { + const [displayedView, setDisplayedView] = useState(null); + + const setEth = useCallback(() => { + setDisplayedView(ETH); + }, []); + + const setNewEth = useCallback(() => { + setDisplayedView(NEW_ETH); + }, []); + + const setSol = useCallback(() => { + setDisplayedView(SOL); + }, []); + + const clear = useCallback(() => { + setDisplayedView(null); + }, []); + + const backHeader = ( + <> +
+ {displayedView} + +
+ + + ); + + const content = + displayedView === null ? ( +
+ + Which action would you like to perform? + +
+ + + +
+
+ ) : displayedView === ETH ? ( + <> + {backHeader} + + + ) : displayedView === NEW_ETH ? ( + <> + {backHeader} + + + ) : displayedView === SOL ? ( + <> + {backHeader} +
+ + ) : null; + + return ( + <> + {CLUSTER === "mainnet" ? null : ( + + + Caution! You are using the {CLUSTER} build of this app. + + + )} + {content} + + ); +} + +export default Home; diff --git a/lp_ui/src/views/Main.tsx b/lp_ui/src/views/Main.tsx index 7bf90aa8..50666425 100644 --- a/lp_ui/src/views/Main.tsx +++ b/lp_ui/src/views/Main.tsx @@ -1,24 +1,23 @@ import addLiquidityTx from "@certusone/wormhole-sdk/lib/migration/addLiquidity"; import getAuthorityAddress from "@certusone/wormhole-sdk/lib/migration/authorityAddress"; import claimSharesTx from "@certusone/wormhole-sdk/lib/migration/claimShares"; -import removeLiquidityTx from "@certusone/wormhole-sdk/lib/migration/removeLiquidity"; import createPoolAccount from "@certusone/wormhole-sdk/lib/migration/createPool"; import getFromCustodyAddress from "@certusone/wormhole-sdk/lib/migration/fromCustodyAddress"; import migrateTokensTx from "@certusone/wormhole-sdk/lib/migration/migrateTokens"; import parsePool from "@certusone/wormhole-sdk/lib/migration/parsePool"; import getPoolAddress from "@certusone/wormhole-sdk/lib/migration/poolAddress"; +import removeLiquidityTx from "@certusone/wormhole-sdk/lib/migration/removeLiquidity"; import getShareMintAddress from "@certusone/wormhole-sdk/lib/migration/shareMintAddress"; import getToCustodyAddress from "@certusone/wormhole-sdk/lib/migration/toCustodyAddress"; import { Button, + CircularProgress, Container, Divider, makeStyles, Paper, TextField, Typography, - CircularProgress, - AppBar, } from "@material-ui/core"; import { ASSOCIATED_TOKEN_PROGRAM_ID, @@ -36,11 +35,7 @@ import SolanaCreateAssociatedAddress, { import SolanaWalletKey from "../components/SolanaWalletKey"; import { useLogger } from "../contexts/Logger"; import { useSolanaWallet } from "../contexts/SolanaWalletContext"; -import { - CLUSTER, - MIGRATION_PROGRAM_ADDRESS, - SOLANA_URL, -} from "../utils/consts"; +import { MIGRATION_PROGRAM_ADDRESS, SOLANA_URL } from "../utils/consts"; import { getMultipleAccounts, signSendAndConfirm } from "../utils/solana"; const useStyles = makeStyles(() => ({ @@ -59,7 +54,7 @@ const useStyles = makeStyles(() => ({ }, })); -const compareWithDecimalOffset = ( +export const compareWithDecimalOffset = ( valueA: string, decimalsA: number, valueB: string, @@ -1002,13 +997,6 @@ function Main() { return ( <> - {CLUSTER === "mainnet" ? null : ( - - - Caution! You are using the {CLUSTER} build of this app. - - - )} diff --git a/lp_ui/src/views/MigrateEthereum.tsx b/lp_ui/src/views/MigrateEthereum.tsx new file mode 100644 index 00000000..afd68805 --- /dev/null +++ b/lp_ui/src/views/MigrateEthereum.tsx @@ -0,0 +1,431 @@ +import { CHAIN_ID_ETH } from "@certusone/wormhole-sdk"; +import { + Button, + CircularProgress, + Container, + Divider, + makeStyles, + Paper, + TextField, + Typography, +} from "@material-ui/core"; +//import { pool_address } from "@certusone/wormhole-sdk/lib/solana/migration/wormhole_migration"; +import { parseUnits } from "ethers/lib/utils"; +import { useCallback, useState } from "react"; +import EthereumSignerKey from "../components/EthereumSignerKey"; +import LogWatcher from "../components/LogWatcher"; +import SmartAddress from "../components/SmartAddress"; +import { useEthereumProvider } from "../contexts/EthereumProviderContext"; +import { useLogger } from "../contexts/Logger"; +import useEthereumMigratorInformation from "../hooks/useEthereumMigratorInformation"; +import { compareWithDecimalOffset } from "./Main"; + +const useStyles = makeStyles(() => ({ + rootContainer: {}, + mainPaper: { + "& > *": { + margin: "1rem", + }, + padding: "2rem", + }, + divider: { + margin: "2rem", + }, + spacer: { + height: "1rem", + }, +})); + +function MigrateEthereum() { + const classes = useStyles(); + const { signer, signerAddress } = useEthereumProvider(); + const { log } = useLogger(); + + const [migratorAddress, setMigratorAddress] = useState(""); + const [refresher, setRefresher] = useState(false); + const forceRefresh = useCallback(() => { + setRefresher((prevState) => !prevState); + }, []); + const poolInfo = useEthereumMigratorInformation( + migratorAddress, + signer, + signerAddress, + refresher + ); + const info = poolInfo.data; + + const [liquidityAmount, setLiquidityAmount] = useState(""); + const [removeLiquidityAmount, setRemoveLiquidityAmount] = useState(""); + const [migrationAmount, setMigrationAmount] = useState(""); + const [redeemAmount, setRedeemAmount] = useState(""); + + const [liquidityIsProcessing, setLiquidityIsProcessing] = useState(false); + const [removeLiquidityIsProcessing, setRemoveLiquidityIsProcessing] = + useState(false); + const [migrationIsProcessing, setMigrationIsProcessing] = useState(false); + const [redeemIsProcessing, setRedeemIsProcessing] = useState(false); + + const addLiquidity = useCallback(async () => { + if (!info) { + return; + } + try { + setLiquidityIsProcessing(true); + await info.toToken.approve( + info.migrator.address, + parseUnits(liquidityAmount, info.toDecimals) + ); + const transaction = await info.migrator.add( + parseUnits(liquidityAmount, info.toDecimals) + ); + await transaction.wait(); + forceRefresh(); + log(`Successfully added liquidity to the pool.`, "success"); + setLiquidityIsProcessing(false); + } catch (e) { + console.error(e); + log(`Could not add liquidity to the pool.`, "error"); + setLiquidityIsProcessing(false); + } + }, [info, liquidityAmount, log, forceRefresh]); + + const removeLiquidity = useCallback(async () => { + if (!info) { + return; + } + try { + setRemoveLiquidityIsProcessing(true); + const transaction = await info.migrator.remove( + parseUnits(removeLiquidityAmount, info.sharesDecimals) + ); + await transaction.wait(); + forceRefresh(); + log(`Successfully removed liquidity from the pool.`, "success"); + setRemoveLiquidityIsProcessing(false); + } catch (e) { + console.error(e); + log(`Could not remove liquidity from the pool.`, "error"); + setRemoveLiquidityIsProcessing(false); + } + }, [info, removeLiquidityAmount, log, forceRefresh]); + + const migrateTokens = useCallback(async () => { + if (!info) { + return; + } + try { + setMigrationIsProcessing(true); + await info.fromToken.approve( + info.migrator.address, + parseUnits(migrationAmount, info.fromDecimals) + ); + const transaction = await info.migrator.migrate( + parseUnits(migrationAmount, info.fromDecimals) + ); + await transaction.wait(); + forceRefresh(); + log(`Successfully migrated tokens.`, "success"); + setMigrationIsProcessing(false); + } catch (e) { + console.error(e); + log(`Could not migrate the tokens.`, "error"); + setMigrationIsProcessing(false); + } + }, [info, migrationAmount, log, forceRefresh]); + + const redeemShares = useCallback(async () => { + if (!info) { + return; + } + try { + setRedeemIsProcessing(true); + const transaction = await info.migrator.claim( + parseUnits(redeemAmount, info.sharesDecimals) + ); + await transaction.wait(); + forceRefresh(); + log(`Successfully redeemed shares.`, "success"); + setRedeemIsProcessing(false); + } catch (e) { + console.error(e); + log(`Could not redeem shares.`, "error"); + setRedeemIsProcessing(false); + } + }, [info, redeemAmount, log, forceRefresh]); + + const addToTokensInWallet = + info && + liquidityAmount && + compareWithDecimalOffset( + liquidityAmount, + info.toDecimals, + info.toWalletBalance, + info.toDecimals + ) !== 1; + const addLiquidityIsReady = addToTokensInWallet; + const addLiquidityUI = ( + <> + Add Liquidity + + This will remove 'To' tokens from your wallet, and give you an equal + number of 'Share' tokens. + + setLiquidityAmount(event.target.value)} + label={"Amount to add"} + > + + {liquidityIsProcessing ? : null} + + ); + + const removeToTokensInPool = + info && + removeLiquidityAmount && + compareWithDecimalOffset( + removeLiquidityAmount, + info.sharesDecimals, + info.toPoolBalance, + info.toDecimals + ) !== 1; + const removeShareTokensInWallet = + info && + removeLiquidityAmount && + compareWithDecimalOffset( + removeLiquidityAmount, + info.sharesDecimals, + info.walletSharesBalance, + info.sharesDecimals + ) !== 1; + const removeLiquidityIsReady = + removeShareTokensInWallet && removeToTokensInPool; + const removeLiquidityUI = ( + <> + Remove Liquidity + + This will remove 'Share' tokens from your wallet, and give you an equal + number of 'To' tokens. + + setRemoveLiquidityAmount(event.target.value)} + label={"Amount to remove"} + > + + {removeLiquidityIsProcessing ? : null} + + ); + + const migrateToTokensInPool = + info && + migrationAmount && + compareWithDecimalOffset( + migrationAmount, + info.fromDecimals, + info.toPoolBalance, + info.toDecimals + ) !== 1; + const migrateFromTokensInWallet = + info && + migrationAmount && + compareWithDecimalOffset( + migrationAmount, + info.fromDecimals, + info.fromWalletBalance, + info.fromDecimals + ) !== 1; + const migrateIsReady = migrateFromTokensInWallet && migrateToTokensInPool; + const migrateTokensUI = ( + <> + Migrate Tokens + + This will remove 'From' tokens from your wallet, and give you an equal + number of 'To' tokens. + + setMigrationAmount(event.target.value)} + label={"Amount to migrate"} + > + + {migrationIsProcessing ? : null} + + ); + + const redeemSharesInWallet = + info && + redeemAmount && + compareWithDecimalOffset( + redeemAmount, + info.sharesDecimals, + info.walletSharesBalance, + info.sharesDecimals + ) !== 1; + const redeemFromTokensInPool = + info && + redeemAmount && + compareWithDecimalOffset( + redeemAmount, + info.sharesDecimals, + info.fromPoolBalance, + info.fromDecimals + ) !== 1; + const redeemIsReady = redeemSharesInWallet && redeemFromTokensInPool; + const redeemSharesUI = ( + <> + Redeem Shares + + This will remove 'Share' tokens from your wallet, and give you an equal + number of 'From' tokens. + + setRedeemAmount(event.target.value)} + label={"Amount to redeem"} + > + + {redeemIsProcessing ? : null} + + ); + + const topContent = ( + <> + Manage an Ethereum Pool + + setMigratorAddress(event.target.value)} + label={"Migrator Address"} + fullWidth + style={{ display: "block" }} + /> + + ); + const infoDisplay = poolInfo.isLoading ? ( + + ) : poolInfo.error ? ( + {poolInfo.error} + ) : !poolInfo.data ? null : ( + <> +
+
+ Pool Balances + + {`'From' Asset: `} + {info?.fromPoolBalance} + + + + {`'To' Asset: `} + {info?.toPoolBalance} + + +
+
+
+ Connected Wallet Balances + + {`'From' Asset: `} + {info?.fromWalletBalance} + + + + {`'To' Asset: `} + {info?.toWalletBalance} + + + + {`'Shares' Asset: `} + {info?.walletSharesBalance} + + +
+
+ + + ); + + const actionPanel = poolInfo.data ? ( + <> + {addLiquidityUI} + + {removeLiquidityUI} + + {redeemSharesUI} + + {migrateTokensUI} + + ) : null; + + return ( + <> + + + {topContent} + {infoDisplay && ( + <> + + {infoDisplay} + + )} + {actionPanel && ( + <> + + {actionPanel} + + )} + + + + + ); +} + +export default MigrateEthereum;