diff --git a/lp_ui/src/components/ButtonWithLoader.tsx b/lp_ui/src/components/ButtonWithLoader.tsx new file mode 100644 index 00000000..be00a62e --- /dev/null +++ b/lp_ui/src/components/ButtonWithLoader.tsx @@ -0,0 +1,72 @@ +import { + Button, + CircularProgress, + makeStyles, + Typography, +} from "@material-ui/core"; +import { ReactChild } from "react"; + +const useStyles = makeStyles((theme) => ({ + root: { + position: "relative", + }, + button: { + marginTop: theme.spacing(2), + textTransform: "none", + width: "100%", + }, + loader: { + position: "absolute", + bottom: 0, + left: "50%", + marginLeft: -12, + marginBottom: 6, + }, + error: { + marginTop: theme.spacing(1), + textAlign: "center", + }, +})); + +export default function ButtonWithLoader({ + disabled, + onClick, + showLoader, + error, + children, +}: { + disabled?: boolean; + onClick: () => void; + showLoader?: boolean; + error?: string; + children: ReactChild; +}) { + const classes = useStyles(); + return ( + <> +
+ + {showLoader ? ( + + ) : null} +
+ {error ? ( + + {error} + + ) : null} + + ); +} diff --git a/lp_ui/src/components/ErrorBoundary.js b/lp_ui/src/components/ErrorBoundary.js new file mode 100644 index 00000000..99eb901b --- /dev/null +++ b/lp_ui/src/components/ErrorBoundary.js @@ -0,0 +1,29 @@ +import { Typography } from "@material-ui/core"; +import React from "react"; + +export default class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + console.error(error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( + + "An unexpected error has occurred. Please refresh the page." + + ); + } + + return this.props.children; + } +} diff --git a/lp_ui/src/components/SolanaCreateAssociatedAddress.tsx b/lp_ui/src/components/SolanaCreateAssociatedAddress.tsx new file mode 100644 index 00000000..160803c4 --- /dev/null +++ b/lp_ui/src/components/SolanaCreateAssociatedAddress.tsx @@ -0,0 +1,146 @@ +import { ChainId, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; +import { Typography } from "@material-ui/core"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + Token, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { Connection, PublicKey, Transaction } from "@solana/web3.js"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSolanaWallet } from "../contexts/SolanaWalletContext"; +import { SOLANA_URL } from "../utils/consts"; +import { signSendAndConfirm } from "../utils/solana"; +import ButtonWithLoader from "./ButtonWithLoader"; + +export function useAssociatedAccountExistsState( + mintAddress: string | null | undefined, + readableTargetAddress: string | undefined +) { + const [associatedAccountExists, setAssociatedAccountExists] = useState(true); // for now, assume it exists until we confirm it doesn't + const solanaWallet = useSolanaWallet(); + const solPK = solanaWallet?.publicKey; + useEffect(() => { + setAssociatedAccountExists(true); + if (!mintAddress || !readableTargetAddress || !solPK) return; + let cancelled = false; + (async () => { + const connection = new Connection(SOLANA_URL, "confirmed"); + const mintPublicKey = new PublicKey(mintAddress); + const payerPublicKey = new PublicKey(solPK); // currently assumes the wallet is the owner + const associatedAddress = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + mintPublicKey, + payerPublicKey + ); + const match = associatedAddress.toString() === readableTargetAddress; + if (match) { + const associatedAddressInfo = await connection.getAccountInfo( + associatedAddress + ); + if (!associatedAddressInfo) { + if (!cancelled) { + setAssociatedAccountExists(false); + } + } + } + })(); + return () => { + cancelled = true; + }; + }, [mintAddress, readableTargetAddress, solPK]); + return useMemo( + () => ({ associatedAccountExists, setAssociatedAccountExists }), + [associatedAccountExists] + ); +} + +export default function SolanaCreateAssociatedAddress({ + mintAddress, + readableTargetAddress, + associatedAccountExists, + setAssociatedAccountExists, +}: { + mintAddress: string | undefined; + readableTargetAddress: string | undefined; + associatedAccountExists: boolean; + setAssociatedAccountExists: (associatedAccountExists: boolean) => void; +}) { + const [isCreating, setIsCreating] = useState(false); + const solanaWallet = useSolanaWallet(); + const solPK = solanaWallet?.publicKey; + const handleClick = useCallback(() => { + if ( + associatedAccountExists || + !mintAddress || + !readableTargetAddress || + !solPK + ) + return; + (async () => { + try { + const connection = new Connection(SOLANA_URL, "confirmed"); + const mintPublicKey = new PublicKey(mintAddress); + const payerPublicKey = new PublicKey(solPK); // currently assumes the wallet is the owner + const associatedAddress = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + mintPublicKey, + payerPublicKey + ); + const match = associatedAddress.toString() === readableTargetAddress; + if (match) { + const associatedAddressInfo = await connection.getAccountInfo( + associatedAddress + ); + if (!associatedAddressInfo) { + setIsCreating(true); + const transaction = new Transaction().add( + await Token.createAssociatedTokenAccountInstruction( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + mintPublicKey, + associatedAddress, + payerPublicKey, // owner + payerPublicKey // payer + ) + ); + const { blockhash } = await connection.getRecentBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = new PublicKey(payerPublicKey); + await signSendAndConfirm(solanaWallet, connection, transaction); + setIsCreating(false); + setAssociatedAccountExists(true); + } + } + } catch (e) { + console.log("cannot create specified spl token account"); + console.error(e); + } + })(); + }, [ + associatedAccountExists, + setAssociatedAccountExists, + mintAddress, + solPK, + readableTargetAddress, + solanaWallet, + ]); + if (associatedAccountExists) return null; + return ( + <> + + This associated token account doesn't exist. + + + Create Associated Token Account + + + ); +} diff --git a/lp_ui/src/index.js b/lp_ui/src/index.js index 05760f4e..a26384d2 100644 --- a/lp_ui/src/index.js +++ b/lp_ui/src/index.js @@ -2,17 +2,20 @@ import { CssBaseline } from "@material-ui/core"; import { ThemeProvider } from "@material-ui/core/styles"; import ReactDOM from "react-dom"; import App from "./App"; +import ErrorBoundary from "./components/ErrorBoundary"; import { LoggerProvider } from "./contexts/Logger"; import { SolanaWalletProvider } from "./contexts/SolanaWalletContext"; import { theme } from "./muiTheme"; ReactDOM.render( - - - - - - - - , + + + + + + + + + + , document.getElementById("root") ); diff --git a/lp_ui/src/views/Main.tsx b/lp_ui/src/views/Main.tsx index 6a132e60..f1bc9606 100644 --- a/lp_ui/src/views/Main.tsx +++ b/lp_ui/src/views/Main.tsx @@ -5,6 +5,7 @@ import { Paper, TextField, Button, + Divider, } from "@material-ui/core"; //import { pool_address } from "@certusone/wormhole-sdk/lib/solana/migration/wormhole_migration"; import { useCallback, useEffect, useState } from "react"; @@ -24,6 +25,19 @@ import getPoolAddress from "@certusone/wormhole-sdk/lib/migration/poolAddress"; import getFromCustodyAddress from "@certusone/wormhole-sdk/lib/migration/fromCustodyAddress"; import getToCustodyAddress from "@certusone/wormhole-sdk/lib/migration/toCustodyAddress"; import getShareMintAddress from "@certusone/wormhole-sdk/lib/migration/shareMintAddress"; +import parsePool from "@certusone/wormhole-sdk/lib/migration/parsePool"; +import addLiquidityTx from "@certusone/wormhole-sdk/lib/migration/addLiquidity"; +import claimSharesTx from "@certusone/wormhole-sdk/lib/migration/claimShares"; +import migrateTokensTx from "@certusone/wormhole-sdk/lib/migration/migrateTokens"; + +import SolanaCreateAssociatedAddress, { + useAssociatedAccountExistsState, +} from "../components/SolanaCreateAssociatedAddress"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + Token, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; const useStyles = makeStyles(() => ({ rootContainer: {}, @@ -33,6 +47,9 @@ const useStyles = makeStyles(() => ({ }, padding: "2rem", }, + divider: { + margin: "2rem", + }, })); function Main() { @@ -43,20 +60,56 @@ function Main() { const [fromMint, setFromMint] = useState(""); const [toMint, setToMint] = useState(""); + const [shareMintAddress, setShareMintAddress] = useState( + undefined + ); const [poolAddress, setPoolAddress] = useState(""); - const [poolExists, setPoolExists] = useState(null); - const [poolAccountInfo, setPoolAccountInfo] = useState(null); - const [shareTokenMint, setShareTokenMint] = useState(null); - const [toTokenAccount, setToTokenAccount] = useState(null); - const [fromTokenAccount, setFromTokenAccount] = useState(null); - const [shareTokenAccount, setShareTokenAccount] = useState(null); + const [poolExists, setPoolExists] = useState(undefined); + const [poolAccountInfo, setPoolAccountInfo] = useState(undefined); + const [parsedPoolData, setParsedPoolData] = useState(undefined); + + //These are the user's personal token accounts corresponding to the mints for the connected wallet + const [fromTokenAccount, setFromTokenAccount] = useState( + undefined + ); + const [toTokenAccount, setToTokenAccount] = useState( + undefined + ); + const [shareTokenAccount, setShareTokenAccount] = useState< + string | undefined + >(undefined); + + //These hooks detect if the connected wallet has the requisite token accounts + const { + associatedAccountExists: fromTokenAccountExists, + setAssociatedAccountExists: setFromTokenAccountExists, + } = useAssociatedAccountExistsState(fromMint, fromTokenAccount); + const { + associatedAccountExists: toTokenAccountExists, + setAssociatedAccountExists: setToTokenAccountExists, + } = useAssociatedAccountExistsState(toMint, toTokenAccount); + const { + associatedAccountExists: shareTokenAccountExists, + setAssociatedAccountExists: setShareTokenAccountExists, + } = useAssociatedAccountExistsState(shareMintAddress, shareTokenAccount); //these are all the other derived information - const [authorityAddress, setAuthorityAddress] = useState(null); - const [fromCustodyAddress, setFromCustodyAddress] = useState(null); - const [toCustodyAddress, setToCustodyAddress] = useState(null); - const [shareMintAddress, setShareMintAddress] = useState(null); + const [authorityAddress, setAuthorityAddress] = useState( + undefined + ); + const [fromCustodyAddress, setFromCustodyAddress] = useState< + string | undefined + >(undefined); + const [toCustodyAddress, setToCustodyAddress] = useState( + undefined + ); + + const [toggleAllData, setToggleAllData] = useState(false); + + const [liquidityAmount, setLiquidityAmount] = useState(""); + const [migrationAmount, setMigrationAmount] = useState(""); + const [redeemAmount, setRedeemAmount] = useState(""); /* Effects*** @@ -82,8 +135,8 @@ function Main() { //Retrieve the poolAccount every time the pool address changes. useEffect(() => { if (poolAddress) { - setPoolAccountInfo(null); - setPoolExists(null); + setPoolAccountInfo(undefined); + setPoolExists(undefined); try { getMultipleAccounts( connection, @@ -92,6 +145,13 @@ function Main() { ).then((result) => { if (result.length && result[0] !== null) { setPoolAccountInfo(result[0]); + parsePool(result[0].data).then( + (parsed) => setParsedPoolData(parsed), + (error) => { + logger.log("Failed to parse the pool data."); + console.error(error); + } + ); setPoolExists(true); logger.log("Successfully found account info for the pool."); } else if (result.length && result[0] === null) { @@ -109,21 +169,71 @@ function Main() { } }, [poolAddress]); + //Set all the addresses which derive from poolAddress useEffect(() => { getAuthorityAddress(MIGRATION_PROGRAM_ADDRESS).then((result: any) => - setAuthorityAddress(result) + setAuthorityAddress(new PublicKey(result).toString()) ); getToCustodyAddress(MIGRATION_PROGRAM_ADDRESS, poolAddress).then( - (result: any) => setToCustodyAddress(result) + (result: any) => setToCustodyAddress(new PublicKey(result).toString()) ); getFromCustodyAddress(MIGRATION_PROGRAM_ADDRESS, poolAddress).then( - (result: any) => setFromCustodyAddress(result) + (result: any) => setFromCustodyAddress(new PublicKey(result).toString()) ); getShareMintAddress(MIGRATION_PROGRAM_ADDRESS, poolAddress).then( - (result: any) => setShareMintAddress(result) + (result: any) => setShareMintAddress(new PublicKey(result).toString()) ); }, [poolAddress]); + + //Set the associated token accounts when the designated mint changes + useEffect(() => { + if (wallet?.publicKey && fromMint) { + Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + new PublicKey(fromMint), + wallet?.publicKey || new PublicKey([]) + ).then( + (result) => { + setFromTokenAccount(result.toString()); + }, + (error) => {} + ); + } + }, [fromMint, wallet?.publicKey]); + + useEffect(() => { + if (wallet?.publicKey && toMint) { + Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + new PublicKey(toMint), + wallet?.publicKey || new PublicKey([]) + ).then( + (result) => { + setToTokenAccount(result.toString()); + }, + (error) => {} + ); + } + }, [toMint, wallet?.publicKey]); + + useEffect(() => { + if (wallet?.publicKey && shareMintAddress) { + Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + new PublicKey(shareMintAddress || ""), + wallet?.publicKey || new PublicKey([]) + ).then( + (result) => { + setShareTokenAccount(result.toString()); + }, + (error) => {} + ); + } + }, [shareMintAddress, wallet?.publicKey]); /* End Effects! */ @@ -136,6 +246,14 @@ function Main() { */ const createPool = async () => { + console.log( + "createPool with these args", + connection, + wallet?.publicKey?.toString(), + MIGRATION_PROGRAM_ADDRESS, + fromMint, + toMint + ); try { const instruction = await createPoolAccount( connection, @@ -148,8 +266,8 @@ function Main() { signSendAndConfirm(wallet, connection, instruction).then( (transaction: any) => { - setPoolExists(null); //Set these to null to force a fetch on them - setPoolAccountInfo(null); + setPoolExists(undefined); //Set these to null to force a fetch on them + setPoolAccountInfo(undefined); logger.log("Successfully created the pool."); }, (error) => { @@ -162,6 +280,96 @@ function Main() { console.error(e); } }; + + const addLiquidity = async () => { + try { + const instruction = await addLiquidityTx( + connection, + wallet?.publicKey?.toString() || "", + MIGRATION_PROGRAM_ADDRESS, + fromMint, + toMint, + toTokenAccount || "", + shareTokenAccount || "", + BigInt(liquidityAmount) + ); + + signSendAndConfirm(wallet, connection, instruction).then( + (transaction: any) => { + setPoolExists(undefined); //Set these to null to force a fetch on them + setPoolAccountInfo(undefined); + logger.log("Successfully added liquidity to the pool."); + }, + (error) => { + logger.log("Could not complete the addLiquidity transaction"); + console.error(error); + } + ); + } catch (e) { + logger.log("Could not complete the addLiquidity transaction"); + console.error(e); + } + }; + + const migrateTokens = async () => { + try { + const instruction = await migrateTokensTx( + connection, + wallet?.publicKey?.toString() || "", + MIGRATION_PROGRAM_ADDRESS, + fromMint, + toMint, + fromTokenAccount || "", + toTokenAccount || "", + BigInt(migrationAmount) + ); + + signSendAndConfirm(wallet, connection, instruction).then( + (transaction: any) => { + setPoolExists(undefined); //Set these to null to force a fetch on them + setPoolAccountInfo(undefined); + logger.log("Successfully migrated the tokens."); + }, + (error) => { + logger.log("Could not complete the migrateTokens transaction."); + console.error(error); + } + ); + } catch (e) { + logger.log("Could not complete the migrateTokens transaction."); + console.error(e); + } + }; + + const redeemShares = async () => { + try { + const instruction = await claimSharesTx( + connection, + wallet?.publicKey?.toString() || "", + MIGRATION_PROGRAM_ADDRESS, + fromMint, + toMint, + toTokenAccount || "", + shareTokenAccount || "", + BigInt(redeemAmount) + ); + + signSendAndConfirm(wallet, connection, instruction).then( + (transaction: any) => { + setPoolExists(undefined); //Set these to null to force a fetch on them + setPoolAccountInfo(undefined); + logger.log("Successfully redeemed the shares."); + }, + (error) => { + logger.log("Could not complete the claimShares transaction."); + console.error(error); + } + ); + } catch (e) { + logger.log("Could not complete the claimShares transaction."); + console.error(e); + } + }; /* End actions! */ @@ -194,35 +402,156 @@ function Main() { ); - const addLiquidity = ( + const addLiquidityUI = ( <> - - Add 'to' tokens to this pool, and receive liquidity tokens. + Add Liquidity + + This will remove 'To' tokens from your wallet, and give you an equal + number of 'Share' tokens. setToMint(event.target.value)} - label={"To Token"} + value={liquidityAmount} + type="number" + onChange={(event) => setLiquidityAmount(event.target.value)} + label={"Amount to add"} > + ); + 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 add"} + > + + + ); + + 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 add"} + > + + + ); + + const relevantTokenAccounts = ( + <> + Your Relevant Token Accounts: + + {"'From' SPL Token Account: " + fromTokenAccount} + + + + {"'To' SPL Token Account: " + toTokenAccount} + + + + {"Share SPL Token Account: " + shareTokenAccount} + + + + ); + + const poolInfo = ( +
+ { + + } + {toggleAllData ? ( + <> + {"Pool Address: " + poolAddress} + {"Pool has been instantiated: " + poolExists} + {"'From' Token Mint Address: " + fromMint} + {"'To' Token Mint Address: " + toMint} + {"Share Token Mint: " + shareMintAddress} + {"Authority Address: " + authorityAddress} + + {"'From' Custody Mint: " + fromCustodyAddress} + + {"'To' Custody Mint: " + toCustodyAddress} + + {"Full Parsed Data for Pool: " + JSON.stringify(parsedPoolData)} + + + ) : null} +
+ ); + const mainContent = ( <> {toAndFromSelector} + + {poolInfo} {createPoolButton} + + {relevantTokenAccounts} + + {addLiquidityUI} + + {redeemSharesUI} + + {migrateTokensUI} ); const content = !wallet.publicKey ? ( Please connect your wallet. + ) : !poolAddress ? ( + toAndFromSelector ) : ( mainContent );