Add support for admin-controlled pools

This commit is contained in:
Gary Wang 2020-12-10 03:20:39 +08:00
parent f7057035fc
commit 42fec272c9
8 changed files with 512 additions and 25 deletions

View File

@ -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",

View File

@ -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<PublicKey | null>(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) => (
<AssetInput setInitialAssets={setInitialAssets} index={i} key={i} />
))}
<Form.Item
label={
<Tooltip title="Whether the assets in the pool can be controlled by the pool admin.">
Admin Controlled
</Tooltip>
}
name="adminControlled"
>
<Switch
checked={adminControlled}
onChange={(checked) => setAdminControlled(checked)}
/>
</Form.Item>
<Form.Item label=" " colon={false}>
<Button
type="primary"

View File

@ -0,0 +1,421 @@
import React, { FormEvent, useState } from 'react';
import { AdminControlledPoolInstructions, PoolInfo } from '@project-serum/pool';
import { TokenInstructions } from '@project-serum/serum';
import FloatingElement from '../../../components/layout/FloatingElement';
import { useConnection } from '../../../utils/connection';
import { useWallet } from '../../../utils/wallet';
import {
getSelectedTokenAccountForMint,
useTokenAccounts,
} from '../../../utils/markets';
import { sendTransaction } from '../../../utils/send';
import { notify } from '../../../utils/notifications';
import { PublicKey, Transaction } from '@solana/web3.js';
import { Button, Input, Tabs } from 'antd';
import {
createAssociatedTokenAccount,
getAssociatedTokenAddress,
} from '@project-serum/associated-token';
import { parseTokenMintData } from '../../../utils/tokens';
import BN from 'bn.js';
import { refreshAllCaches } from '../../../utils/fetch-loop';
const { TabPane } = Tabs;
export function PoolAdminPanel({ poolInfo }: { poolInfo: PoolInfo }) {
return (
<FloatingElement>
<Tabs>
<TabPane tab="Pause/Unpause" key="pause">
<PauseUnpauseTab poolInfo={poolInfo} />
</TabPane>
<TabPane tab="Add Token" key="addAsset">
<AddAssetTab poolInfo={poolInfo} />
</TabPane>
<TabPane tab="Remove Token" key="removeAsset">
<RemoveAssetTab poolInfo={poolInfo} />
</TabPane>
<TabPane tab="Deposit" key="deposit">
<DepositTab poolInfo={poolInfo} />
</TabPane>
<TabPane tab="Withdraw" key="withdraw">
<WithdrawTab poolInfo={poolInfo} />
</TabPane>
<TabPane tab="Change Fee" key="updateFee">
<UpdateFeeTab poolInfo={poolInfo} />
</TabPane>
</Tabs>
</FloatingElement>
);
}
interface TabParams {
poolInfo: PoolInfo;
}
function PauseUnpauseTab({ poolInfo }: TabParams) {
const connection = useConnection();
const { wallet, connected } = useWallet();
const [submitting, setSubmitting] = useState(false);
async function sendPause() {
if (!connected) {
return;
}
setSubmitting(true);
try {
const transaction = new Transaction();
transaction.add(AdminControlledPoolInstructions.pause(poolInfo));
await sendTransaction({ connection, wallet, transaction });
} catch (e) {
notify({
message: 'Error pausing pool: ' + e.message,
type: 'error',
});
} finally {
setSubmitting(false);
}
}
async function sendUnpause() {
if (!connected) {
return;
}
setSubmitting(true);
try {
const transaction = new Transaction();
transaction.add(AdminControlledPoolInstructions.unpause(poolInfo));
await sendTransaction({ connection, wallet, transaction });
} catch (e) {
notify({
message: 'Error unpausing pool: ' + e.message,
type: 'error',
});
} finally {
setSubmitting(false);
}
}
return (
<>
<Button onClick={sendPause} disabled={submitting}>
Pause
</Button>{' '}
<Button onClick={sendUnpause} disabled={submitting}>
Unpause
</Button>
</>
);
}
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 (
<form onSubmit={onSubmit}>
<Input
addonBefore={<>Token Mint Address</>}
value={address}
onChange={(e) => setAddress(e.target.value.trim())}
style={{ marginBottom: 24 }}
/>
<SubmitButton canSubmit={canSubmit} submitting={submitting} />
</form>
);
}
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 (
<form onSubmit={onSubmit}>
<Input
addonBefore={<>Token Mint Address</>}
value={address}
onChange={(e) => setAddress(e.target.value.trim())}
style={{ marginBottom: 24 }}
/>
<SubmitButton canSubmit={canSubmit} submitting={submitting} />
</form>
);
}
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 (
<form onSubmit={onSubmit}>
<Input
addonBefore={<>Token Mint Address</>}
value={address}
onChange={(e) => setAddress(e.target.value.trim())}
style={{ marginBottom: 24 }}
/>
<Input
addonBefore={<>Quantity</>}
value={quantity}
onChange={(e) => setQuantity(e.target.value.trim())}
style={{ marginBottom: 24 }}
/>
<SubmitButton canSubmit={canSubmit} submitting={submitting} />
</form>
);
}
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 (
<form onSubmit={onSubmit}>
<Input
addonBefore={<>Token Mint Address</>}
value={address}
onChange={(e) => setAddress(e.target.value.trim())}
style={{ marginBottom: 24 }}
/>
<Input
addonBefore={<>Quantity</>}
value={quantity}
onChange={(e) => setQuantity(e.target.value.trim())}
style={{ marginBottom: 24 }}
/>
<SubmitButton canSubmit={canSubmit} submitting={submitting} />
</form>
);
}
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 (
<form onSubmit={onSubmit}>
<Input
addonBefore={<>Fee Rate</>}
value={feeRate}
onChange={(e) => setFeeRate(e.target.value.trim())}
style={{ marginBottom: 24 }}
/>
<SubmitButton canSubmit={canSubmit} submitting={submitting} />
</form>
);
}
function useOnSubmitHandler(
description: string,
makeTransaction: () => Promise<Transaction | null | undefined>,
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 (
<Button
htmlType="submit"
type="primary"
disabled={!canSubmit || submitting}
>
{!connected ? 'Wallet not connected' : 'Submit'}
</Button>
);
}

View File

@ -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:{' '}
<Text copyable>{poolInfo.state.poolTokenMint.toBase58()}</Text>
</Paragraph>
{poolInfo.state.adminKey ? (
<Paragraph>
Pool admin: <Text copyable>{poolInfo.state.adminKey.toBase58()}</Text>
</Paragraph>
) : null}
<Paragraph>
Fee rate: {feeFormat.format(poolInfo.state.feeRate / 1_000_000)}
</Paragraph>
<Paragraph>
Total supply: {mintInfo.supply.toNumber() / 10 ** mintInfo.decimals}
</Paragraph>

View File

@ -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() {
<Col xs={24}>
<PoolBalancesPanel poolInfo={poolInfo} />
</Col>
{wallet.connected &&
poolInfo.state.adminKey?.equals(wallet.publicKey) &&
isAdminControlledPool(poolInfo) ? (
<Col xs={24}>
<PoolAdminPanel poolInfo={poolInfo} />
</Col>
) : null}
</Row>
</>
);

View File

@ -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<Buffer> | 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<AccountInfo<Buffer> | 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) {

View File

@ -177,6 +177,7 @@ class FetchLoops {
loop.removeListener(listener);
if (loop.stopped) {
this.loops.delete(listener.cacheKey);
globalCache.delete(listener.cacheKey);
}
}

View File

@ -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"