From 42fec272c96d14a9cc640045552e340707e5464d Mon Sep 17 00:00:00 2001 From: Gary Wang Date: Thu, 10 Dec 2020 03:20:39 +0800 Subject: [PATCH] Add support for admin-controlled pools --- package.json | 3 +- src/pages/pools/NewPoolPage.tsx | 21 +- src/pages/pools/PoolPage/PoolAdminPanel.tsx | 421 ++++++++++++++++++++ src/pages/pools/PoolPage/PoolInfoPanel.tsx | 22 +- src/pages/pools/PoolPage/index.tsx | 16 +- src/utils/connection.tsx | 30 +- src/utils/fetch-loop.tsx | 1 + yarn.lock | 23 +- 8 files changed, 512 insertions(+), 25 deletions(-) create mode 100644 src/pages/pools/PoolPage/PoolAdminPanel.tsx diff --git a/package.json b/package.json index 65923a6..db42a71 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "dependencies": { "@ant-design/icons": "^4.2.1", "@craco/craco": "^5.6.4", + "@project-serum/associated-token": "0.1.0", "@project-serum/awesome-serum": "1.0.1", - "@project-serum/pool": "^0.1.1", + "@project-serum/pool": "^0.2.0", "@project-serum/serum": "^0.13.14", "@project-serum/sol-wallet-adapter": "^0.1.1", "@solana/web3.js": "0.86.1", diff --git a/src/pages/pools/NewPoolPage.tsx b/src/pages/pools/NewPoolPage.tsx index 04d44d6..b6ab69c 100644 --- a/src/pages/pools/NewPoolPage.tsx +++ b/src/pages/pools/NewPoolPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Button, Form, Input, Tooltip, Typography } from 'antd'; +import { Button, Form, Input, Switch, Tooltip, Typography } from 'antd'; import { PublicKey } from '@solana/web3.js'; import { useConnection } from '../../utils/connection'; import FloatingElement from '../../components/layout/FloatingElement'; @@ -28,7 +28,7 @@ const AddRemoveTokenButtons = styled.div` margin-bottom: 16px; `; -const DEFAULT_PROGRAM_ID = '8qZoqDMXTfLZz6BYrDfD5Cuy65JKkaNwktb54hj1yaoK'; +const DEFAULT_PROGRAM_ID = 'DL7L4cFHwmfNevZRg92rF5unbdUvFGoiuMrQ4aV7Nzsc'; export default function NewPoolPage() { const connection = useConnection(); @@ -40,6 +40,7 @@ export default function NewPoolPage() { { valid: false }, { valid: false }, ]); + const [adminControlled, setAdminControlled] = useState(false); const [tokenAccounts] = useTokenAccounts(); const [submitting, setSubmitting] = useState(false); const [newPoolAddress, setNewPoolAddress] = useState(null); @@ -83,6 +84,9 @@ export default function NewPoolPage() { } return found.pubkey; }), + additionalAccounts: adminControlled + ? [{ pubkey: wallet.publicKey, isSigner: false, isWritable: false }] + : [], }); const signed = await Promise.all( transactions.map(({ transaction, signers }) => @@ -172,6 +176,19 @@ export default function NewPoolPage() { {initialAssets.map((asset, i) => ( ))} + + Admin Controlled + + } + name="adminControlled" + > + setAdminControlled(checked)} + /> + {' '} + + + ); +} + +function AddAssetTab({ poolInfo }: TabParams) { + const connection = useConnection(); + const [address, setAddress] = useState(''); + const { wallet, connected } = useWallet(); + const canSubmit = connected && address; + const [onSubmit, submitting] = useOnSubmitHandler( + 'adding asset to pool', + async () => { + const mintAddress = new PublicKey(address); + const vaultAddress = await getAssociatedTokenAddress( + poolInfo.state.vaultSigner, + mintAddress, + ); + const transaction = new Transaction(); + if (!(await connection.getAccountInfo(vaultAddress))) { + transaction.add( + await createAssociatedTokenAccount( + wallet.publicKey, + poolInfo.state.vaultSigner, + mintAddress, + ), + ); + } + transaction.add( + AdminControlledPoolInstructions.addAsset(poolInfo, vaultAddress), + ); + return transaction; + }, + ); + + return ( +
+ Token Mint Address} + value={address} + onChange={(e) => setAddress(e.target.value.trim())} + style={{ marginBottom: 24 }} + /> + + + ); +} + +function RemoveAssetTab({ poolInfo }: TabParams) { + const [address, setAddress] = useState(''); + const { connected } = useWallet(); + const canSubmit = connected && address; + const [onSubmit, submitting] = useOnSubmitHandler( + 'removing asset from pool', + async () => { + const mintAddress = new PublicKey(address); + const vaultAddress = poolInfo.state.assets.find((asset) => + asset.mint.equals(mintAddress), + )?.vaultAddress; + if (!vaultAddress) { + throw new Error('Asset not in pool'); + } + const transaction = new Transaction(); + transaction.add( + AdminControlledPoolInstructions.removeAsset(poolInfo, vaultAddress), + ); + return transaction; + }, + ); + + return ( +
+ Token Mint Address} + value={address} + onChange={(e) => setAddress(e.target.value.trim())} + style={{ marginBottom: 24 }} + /> + + + ); +} + +function DepositTab({ poolInfo }: TabParams) { + const [address, setAddress] = useState(''); + const [quantity, setQuantity] = useState(''); + + const connection = useConnection(); + const { wallet, connected } = useWallet(); + const [tokenAccounts] = useTokenAccounts(); + const canSubmit = + connected && address && tokenAccounts && parseFloat(quantity); + + const [onSubmit, submitting] = useOnSubmitHandler( + 'depositing to pool', + async () => { + const mintAddress = new PublicKey(address); + const vaultAddress = poolInfo.state.assets.find((asset) => + asset.mint.equals(mintAddress), + )?.vaultAddress; + if (!vaultAddress) { + throw new Error('Asset not in pool'); + } + + const walletTokenAccount = getSelectedTokenAccountForMint( + tokenAccounts, + mintAddress, + ); + if (!walletTokenAccount) { + throw new Error('Asset not in wallet'); + } + + const mintAccountInfo = await connection.getAccountInfo(mintAddress); + if (!mintAccountInfo) { + throw new Error('Mint not found'); + } + const mintDecimals = parseTokenMintData(mintAccountInfo.data).decimals; + const parsedQuantity = Math.round( + parseFloat(quantity) * 10 ** mintDecimals, + ); + + const transaction = new Transaction(); + transaction.add( + TokenInstructions.transfer({ + source: walletTokenAccount.pubkey, + destination: vaultAddress, + amount: parsedQuantity, + owner: wallet.publicKey, + }), + ); + return transaction; + }, + true, + ); + + return ( +
+ Token Mint Address} + value={address} + onChange={(e) => setAddress(e.target.value.trim())} + style={{ marginBottom: 24 }} + /> + Quantity} + value={quantity} + onChange={(e) => setQuantity(e.target.value.trim())} + style={{ marginBottom: 24 }} + /> + + + ); +} + +function WithdrawTab({ poolInfo }: TabParams) { + const [address, setAddress] = useState(''); + const [quantity, setQuantity] = useState(''); + + const connection = useConnection(); + const { wallet, connected } = useWallet(); + const [tokenAccounts] = useTokenAccounts(); + const canSubmit = + connected && address && tokenAccounts && parseFloat(quantity); + + const [onSubmit, submitting] = useOnSubmitHandler( + 'withdrawing from pool', + async () => { + const mintAddress = new PublicKey(address); + const vaultAddress = poolInfo.state.assets.find((asset) => + asset.mint.equals(mintAddress), + )?.vaultAddress; + if (!vaultAddress) { + throw new Error('Asset not in pool'); + } + + const walletTokenAccount = getSelectedTokenAccountForMint( + tokenAccounts, + mintAddress, + ); + if (!walletTokenAccount) { + throw new Error('Asset not in wallet'); + } + + const mintAccountInfo = await connection.getAccountInfo(mintAddress); + if (!mintAccountInfo) { + throw new Error('Mint not found'); + } + const mintDecimals = parseTokenMintData(mintAccountInfo.data).decimals; + const parsedQuantity = Math.round( + parseFloat(quantity) * 10 ** mintDecimals, + ); + + const transaction = new Transaction(); + transaction.add( + AdminControlledPoolInstructions.approveDelegate( + poolInfo, + vaultAddress, + wallet.publicKey, + new BN(parsedQuantity), + ), + TokenInstructions.transfer({ + source: vaultAddress, + destination: walletTokenAccount.pubkey, + amount: parsedQuantity, + owner: wallet.publicKey, + }), + ); + return transaction; + }, + ); + + return ( +
+ Token Mint Address} + value={address} + onChange={(e) => setAddress(e.target.value.trim())} + style={{ marginBottom: 24 }} + /> + Quantity} + value={quantity} + onChange={(e) => setQuantity(e.target.value.trim())} + style={{ marginBottom: 24 }} + /> + + + ); +} + +function UpdateFeeTab({ poolInfo }: TabParams) { + const [feeRate, setFeeRate] = useState(''); + + const { connected } = useWallet(); + const [tokenAccounts] = useTokenAccounts(); + const canSubmit = connected && tokenAccounts && parseFloat(feeRate); + + const [onSubmit, submitting] = useOnSubmitHandler( + 'changing pool fee', + async () => { + const transaction = new Transaction(); + transaction.add( + AdminControlledPoolInstructions.updateFee( + poolInfo, + Math.round(parseFloat(feeRate) * 1_000_000), + ), + ); + return transaction; + }, + ); + + return ( +
+ Fee Rate} + value={feeRate} + onChange={(e) => setFeeRate(e.target.value.trim())} + style={{ marginBottom: 24 }} + /> + + + ); +} + +function useOnSubmitHandler( + description: string, + makeTransaction: () => Promise, + refresh = false, +): [(FormEvent) => void, boolean] { + const connection = useConnection(); + const { wallet, connected } = useWallet(); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + if (submitting) { + return; + } + setSubmitting(true); + try { + if (!connected) { + throw new Error('Wallet not connected'); + } + const transaction = await makeTransaction(); + if (!transaction) { + return; + } + await sendTransaction({ connection, wallet, transaction }); + if (refresh) { + refreshAllCaches(); + } + } catch (e) { + notify({ + message: `Error ${description}: ${e.message}`, + type: 'error', + }); + } finally { + setSubmitting(false); + } + } + + return [onSubmit, submitting]; +} + +function SubmitButton({ canSubmit, submitting }) { + const { connected } = useWallet(); + return ( + + ); +} diff --git a/src/pages/pools/PoolPage/PoolInfoPanel.tsx b/src/pages/pools/PoolPage/PoolInfoPanel.tsx index 0df7b05..12ea889 100644 --- a/src/pages/pools/PoolPage/PoolInfoPanel.tsx +++ b/src/pages/pools/PoolPage/PoolInfoPanel.tsx @@ -15,18 +15,18 @@ interface PoolInfoProps { mintInfo: MintInfo; } +const feeFormat = new Intl.NumberFormat(undefined, { + style: 'percent', + minimumFractionDigits: 0, + maximumFractionDigits: 6, +}); + export default function PoolInfoPanel({ poolInfo, mintInfo }: PoolInfoProps) { const connection = useConnection(); const [totalBasket] = useAsyncData( () => getPoolBasket(connection, poolInfo, { redeem: mintInfo.supply }), - tuple( - getPoolBasket, - connection, - poolInfo.address.toBase58(), - 'total', - mintInfo.supply.toString(), - ), + tuple(getPoolBasket, connection, poolInfo, 'total', mintInfo), ); return ( @@ -39,6 +39,14 @@ export default function PoolInfoPanel({ poolInfo, mintInfo }: PoolInfoProps) { Pool token mint address:{' '} {poolInfo.state.poolTokenMint.toBase58()} + {poolInfo.state.adminKey ? ( + + Pool admin: {poolInfo.state.adminKey.toBase58()} + + ) : null} + + Fee rate: {feeFormat.format(poolInfo.state.feeRate / 1_000_000)} + Total supply: {mintInfo.supply.toNumber() / 10 ** mintInfo.decimals} diff --git a/src/pages/pools/PoolPage/index.tsx b/src/pages/pools/PoolPage/index.tsx index 4a40606..e2b998e 100644 --- a/src/pages/pools/PoolPage/index.tsx +++ b/src/pages/pools/PoolPage/index.tsx @@ -4,12 +4,18 @@ import { Col, PageHeader, Row, Spin, Typography } from 'antd'; import { PublicKey } from '@solana/web3.js'; import { useAccountInfo } from '../../../utils/connection'; import FloatingElement from '../../../components/layout/FloatingElement'; -import { decodePoolState, PoolInfo } from '@project-serum/pool'; +import { + decodePoolState, + isAdminControlledPool, + PoolInfo, +} from '@project-serum/pool'; import PoolInfoPanel from './PoolInfoPanel'; import { parseTokenMintData } from '../../../utils/tokens'; import PoolCreateRedeemPanel from './PoolCreateRedeemPanel'; import PoolBalancesPanel from './PoolBalancesPanel'; import { useHistory } from 'react-router-dom'; +import { PoolAdminPanel } from './PoolAdminPanel'; +import { useWallet } from '../../../utils/wallet'; const { Text } = Typography; @@ -41,6 +47,7 @@ export default function PoolPage() { () => (mintAccountInfo ? parseTokenMintData(mintAccountInfo.data) : null), [mintAccountInfo], ); + const { wallet } = useWallet(); if (poolInfo && mintInfo) { return ( @@ -60,6 +67,13 @@ export default function PoolPage() { + {wallet.connected && + poolInfo.state.adminKey?.equals(wallet.publicKey) && + isAdminControlledPool(poolInfo) ? ( + + + + ) : null} ); diff --git a/src/utils/connection.tsx b/src/utils/connection.tsx index d1db9e2..3042e7d 100644 --- a/src/utils/connection.tsx +++ b/src/utils/connection.tsx @@ -1,6 +1,6 @@ import { useLocalStorageState } from './utils'; import { Account, AccountInfo, Connection, PublicKey } from '@solana/web3.js'; -import React, { useContext, useEffect, useMemo } from 'react'; +import React, { useContext, useEffect, useMemo, useRef } from 'react'; import { setCache, useAsyncData } from './fetch-loop'; import tuple from 'immutable-tuple'; import { ConnectionContextValues, EndpointInfo } from './types'; @@ -137,14 +137,15 @@ export function useAccountInfo( let currentItem = accountListenerCount.get(cacheKey); ++currentItem.count; } else { - let previousData: Buffer | null = null; - const subscriptionId = connection.onAccountChange(publicKey, (e) => { - if (e.data) { - if (!previousData || !previousData.equals(e.data)) { - setCache(cacheKey, e); - } else { - } - previousData = e.data; + let previousInfo: AccountInfo | null = null; + const subscriptionId = connection.onAccountChange(publicKey, (info) => { + if ( + !previousInfo || + !previousInfo.data.equals(info.data) || + previousInfo.lamports !== info.lamports + ) { + previousInfo = info; + setCache(cacheKey, info); } }); accountListenerCount.set(cacheKey, { count: 1, subscriptionId }); @@ -161,7 +162,16 @@ export function useAccountInfo( }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [cacheKey]); - return [accountInfo, loaded]; + const previousInfoRef = useRef | null>(null); + if ( + accountInfo && + (!previousInfoRef.current || + !previousInfoRef.current.data.equals(accountInfo.data) || + previousInfoRef.current.lamports !== accountInfo.lamports) + ) { + previousInfoRef.current = accountInfo; + } + return [previousInfoRef.current, loaded]; } export function useAccountData(publicKey) { diff --git a/src/utils/fetch-loop.tsx b/src/utils/fetch-loop.tsx index de8d5f4..830279f 100644 --- a/src/utils/fetch-loop.tsx +++ b/src/utils/fetch-loop.tsx @@ -177,6 +177,7 @@ class FetchLoops { loop.removeListener(listener); if (loop.stopped) { this.loops.delete(listener.cacheKey); + globalCache.delete(listener.cacheKey); } } diff --git a/yarn.lock b/yarn.lock index 5c25c2c..226b0bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1530,16 +1530,31 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== +"@project-serum/associated-token@0.1.0", "@project-serum/associated-token@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@project-serum/associated-token/-/associated-token-0.1.0.tgz#ee817997c24e1089c5b99e370216ecc2d48e6d8b" + integrity sha512-sHDKQoT36TZl5aJct/pEbRtLUX6UXwnkGaA/ibHXkvDkQrtHjtkXEkkwmdhNZQsoXkSaUJdK62jh4c/BUMoZbg== + "@project-serum/awesome-serum@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@project-serum/awesome-serum/-/awesome-serum-1.0.1.tgz#185a348cb57eb00592aee4f8c730b65cc9ae7a22" integrity sha512-WbykMEX2Ja1oeY3n7cVK79XMBxFgWsWxDMxfJ7GL+/88R568vwPvhP57HrBgULZmwu/Zt5CmMWjf6oeD1E621g== -"@project-serum/pool@^0.1.1": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@project-serum/pool/-/pool-0.1.3.tgz#9f99e6046f4565e38896934b24d447eae1527a62" - integrity sha512-wAO65EccmCCMmgolvVXIc3JbSapWxLWALuVuhbHiiYQjTCMolAYvGSzeJwur7Z7MjTuRJMc8z9/6rYNOQSAwaw== +"@project-serum/borsh@^0.0.1-alpha.0": + version "0.0.1-alpha.0" + resolved "https://registry.yarnpkg.com/@project-serum/borsh/-/borsh-0.0.1-alpha.0.tgz#61643cc686c31c6a4f483b3cc069dee5350b463a" + integrity sha512-11eEM7LAwU+NW2Ha7Cc9nRNiTUuvT7zHlcDvAMEVxZbpZXQd09r6xEYgTpK87p9AcoTr56F36Ww5OvPO/LoJHw== dependencies: + bn.js "^5.1.2" + buffer-layout "^1.2.0" + +"@project-serum/pool@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@project-serum/pool/-/pool-0.2.0.tgz#ce79b63a58e2dc7f5ae1387948ee1d91cca64b01" + integrity sha512-Gsd1G+S/7qx/2c49zIG1WF7xjElghRsVXUQewi10Rs7zY0ryS0jmIKHf6pPxn4Bpc0GKZmENrvwe2tChgY3+CA== + dependencies: + "@project-serum/associated-token" "^0.1.0" + "@project-serum/borsh" "^0.0.1-alpha.0" "@project-serum/serum" "^0.13.8" bn.js "^5.1.2" buffer-layout "^1.2.0"