596 lines
17 KiB
TypeScript
596 lines
17 KiB
TypeScript
import React, { FormEvent, useMemo, 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 '@solana/wallet-adapter-react';
|
|
import {
|
|
getSelectedTokenAccountForMint,
|
|
useTokenAccounts,
|
|
} from '../../../utils/markets';
|
|
import { sendTransaction } from '../../../utils/send';
|
|
import { notify } from '../../../utils/notifications';
|
|
import {
|
|
Account,
|
|
PublicKey,
|
|
SystemProgram,
|
|
Transaction,
|
|
} from '@solana/web3.js';
|
|
import { AutoComplete, Button, Input, Select, Tabs } from 'antd';
|
|
import {
|
|
createAssociatedTokenAccount,
|
|
getAssociatedTokenAddress,
|
|
} from '@project-serum/associated-token';
|
|
import { parseTokenMintData, useMintToTickers } from '../../../utils/tokens';
|
|
import { BaseSignerWalletAdapter } from '@solana/wallet-adapter-base';
|
|
import BN from 'bn.js';
|
|
import { refreshAllCaches } from '../../../utils/fetch-loop';
|
|
|
|
const { TabPane } = Tabs;
|
|
const { Option } = Select;
|
|
|
|
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="Modify 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 || !wallet) {
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
const transaction = new Transaction();
|
|
transaction.add(AdminControlledPoolInstructions.pause(poolInfo));
|
|
await sendTransaction({
|
|
connection,
|
|
wallet: wallet.adapter as BaseSignerWalletAdapter,
|
|
transaction,
|
|
});
|
|
} catch (e) {
|
|
notify({
|
|
message: 'Error pausing pool',
|
|
description: e.message,
|
|
type: 'error',
|
|
});
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
async function sendUnpause() {
|
|
if (!connected || !wallet) {
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
const transaction = new Transaction();
|
|
transaction.add(AdminControlledPoolInstructions.unpause(poolInfo));
|
|
await sendTransaction({
|
|
connection,
|
|
wallet: wallet.adapter as BaseSignerWalletAdapter,
|
|
transaction,
|
|
});
|
|
} catch (e) {
|
|
notify({
|
|
message: 'Error unpausing pool',
|
|
description: 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 { connected, publicKey } = 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)) && publicKey) {
|
|
transaction.add(
|
|
await createAssociatedTokenAccount(
|
|
publicKey,
|
|
poolInfo.state.vaultSigner,
|
|
mintAddress,
|
|
),
|
|
);
|
|
}
|
|
transaction.add(
|
|
AdminControlledPoolInstructions.addAsset(poolInfo, vaultAddress),
|
|
);
|
|
return [transaction, []];
|
|
},
|
|
);
|
|
|
|
return (
|
|
<form onSubmit={onSubmit}>
|
|
<MintSelector
|
|
label="Token Mint Address"
|
|
value={address}
|
|
onChange={(value) => setAddress(value)}
|
|
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}>
|
|
<MintInPoolSelector
|
|
poolInfo={poolInfo}
|
|
label="Token Mint Address"
|
|
value={address}
|
|
onChange={(value) => setAddress(value)}
|
|
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 { connected, publicKey } = useWallet();
|
|
const [tokenAccounts] = useTokenAccounts();
|
|
const canSubmit =
|
|
connected && address && tokenAccounts && parseFloat(quantity);
|
|
|
|
const [onSubmit, submitting] = useOnSubmitHandler(
|
|
'depositing to pool',
|
|
async () => {
|
|
if (!publicKey) {
|
|
throw new Error('Wallet is not connected');
|
|
}
|
|
|
|
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 wrappedSolAccount =
|
|
mintAddress.equals(TokenInstructions.WRAPPED_SOL_MINT) &&
|
|
walletTokenAccount.pubkey.equals(publicKey)
|
|
? new Account()
|
|
: null;
|
|
|
|
const transaction = new Transaction();
|
|
const signers: Account[] = [];
|
|
if (wrappedSolAccount) {
|
|
transaction.add(
|
|
SystemProgram.createAccount({
|
|
fromPubkey: publicKey,
|
|
lamports: parsedQuantity + 2.04e6,
|
|
newAccountPubkey: wrappedSolAccount.publicKey,
|
|
programId: TokenInstructions.TOKEN_PROGRAM_ID,
|
|
space: 165,
|
|
}),
|
|
TokenInstructions.initializeAccount({
|
|
account: wrappedSolAccount.publicKey,
|
|
mint: TokenInstructions.WRAPPED_SOL_MINT,
|
|
owner: publicKey,
|
|
}),
|
|
TokenInstructions.transfer({
|
|
source: wrappedSolAccount.publicKey,
|
|
destination: vaultAddress,
|
|
amount: parsedQuantity,
|
|
owner: publicKey,
|
|
}),
|
|
TokenInstructions.closeAccount({
|
|
source: wrappedSolAccount.publicKey,
|
|
destination: walletTokenAccount.pubkey,
|
|
owner: publicKey,
|
|
}),
|
|
);
|
|
signers.push(wrappedSolAccount);
|
|
} else {
|
|
transaction.add(
|
|
TokenInstructions.transfer({
|
|
source: walletTokenAccount.pubkey,
|
|
destination: vaultAddress,
|
|
amount: parsedQuantity,
|
|
owner: publicKey,
|
|
}),
|
|
);
|
|
}
|
|
return [transaction, signers];
|
|
},
|
|
true,
|
|
);
|
|
|
|
return (
|
|
<form onSubmit={onSubmit}>
|
|
<MintInPoolSelector
|
|
poolInfo={poolInfo}
|
|
label="Token Mint Address"
|
|
value={address}
|
|
onChange={(value) => setAddress(value)}
|
|
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 { connected, publicKey } = useWallet();
|
|
const [tokenAccounts] = useTokenAccounts();
|
|
const canSubmit =
|
|
connected && address && tokenAccounts && parseFloat(quantity);
|
|
|
|
const [onSubmit, submitting] = useOnSubmitHandler(
|
|
'withdrawing from pool',
|
|
async () => {
|
|
if (!publicKey) {
|
|
throw new Error('Wallet is not connected');
|
|
}
|
|
|
|
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 wrappedSolAccount =
|
|
mintAddress.equals(TokenInstructions.WRAPPED_SOL_MINT) &&
|
|
walletTokenAccount.pubkey.equals(publicKey)
|
|
? new Account()
|
|
: null;
|
|
|
|
const transaction = new Transaction();
|
|
const signers: Account[] = [];
|
|
if (wrappedSolAccount) {
|
|
transaction.add(
|
|
SystemProgram.createAccount({
|
|
fromPubkey: publicKey,
|
|
lamports: 2.04e6,
|
|
newAccountPubkey: wrappedSolAccount.publicKey,
|
|
programId: TokenInstructions.TOKEN_PROGRAM_ID,
|
|
space: 165,
|
|
}),
|
|
TokenInstructions.initializeAccount({
|
|
account: wrappedSolAccount.publicKey,
|
|
mint: TokenInstructions.WRAPPED_SOL_MINT,
|
|
owner: publicKey,
|
|
}),
|
|
);
|
|
signers.push(wrappedSolAccount);
|
|
}
|
|
transaction.add(
|
|
AdminControlledPoolInstructions.approveDelegate(
|
|
poolInfo,
|
|
vaultAddress,
|
|
publicKey,
|
|
new BN(parsedQuantity),
|
|
),
|
|
);
|
|
if (wrappedSolAccount) {
|
|
transaction.add(
|
|
TokenInstructions.transfer({
|
|
source: vaultAddress,
|
|
destination: wrappedSolAccount.publicKey,
|
|
amount: parsedQuantity,
|
|
owner: publicKey,
|
|
}),
|
|
TokenInstructions.closeAccount({
|
|
source: wrappedSolAccount.publicKey,
|
|
destination: walletTokenAccount.pubkey,
|
|
owner: publicKey,
|
|
}),
|
|
);
|
|
} else {
|
|
transaction.add(
|
|
TokenInstructions.transfer({
|
|
source: vaultAddress,
|
|
destination: walletTokenAccount.pubkey,
|
|
amount: parsedQuantity,
|
|
owner: publicKey,
|
|
}),
|
|
);
|
|
}
|
|
return [transaction, signers];
|
|
},
|
|
);
|
|
|
|
return (
|
|
<form onSubmit={onSubmit}>
|
|
<MintInPoolSelector
|
|
poolInfo={poolInfo}
|
|
label="Token Mint Address"
|
|
value={address}
|
|
onChange={(value) => setAddress(value)}
|
|
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, Account[]]>,
|
|
refresh = false,
|
|
): [(FormEvent) => void, boolean] {
|
|
const connection = useConnection();
|
|
const { connected, wallet } = useWallet();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
async function onSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (submitting) {
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
if (!connected || !wallet) {
|
|
throw new Error('Wallet not connected');
|
|
}
|
|
const [transaction, signers] = await makeTransaction();
|
|
await sendTransaction({
|
|
connection,
|
|
wallet: wallet.adapter as BaseSignerWalletAdapter,
|
|
transaction,
|
|
signers,
|
|
});
|
|
if (refresh) {
|
|
refreshAllCaches();
|
|
}
|
|
} catch (e) {
|
|
notify({
|
|
message: `Error ${description}`,
|
|
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>
|
|
);
|
|
}
|
|
|
|
function MintInPoolSelector({
|
|
poolInfo,
|
|
label,
|
|
value,
|
|
onChange,
|
|
style,
|
|
}: {
|
|
poolInfo: PoolInfo;
|
|
label: string;
|
|
value: string;
|
|
onChange: (string) => void;
|
|
style: any;
|
|
}) {
|
|
const mintToTickers = useMintToTickers();
|
|
return (
|
|
<Input.Group style={style}>
|
|
<span className="ant-input-group-addon">{label}</span>
|
|
<Select onChange={onChange} value={value} style={{ width: '100%' }}>
|
|
{poolInfo.state.assets.map((asset) => (
|
|
<Option value={asset.mint.toBase58()} key={asset.mint.toBase58()}>
|
|
{mintToTickers[asset.mint.toBase58()] ? (
|
|
<>
|
|
{mintToTickers[asset.mint.toBase58()]} ({asset.mint.toBase58()})
|
|
</>
|
|
) : (
|
|
asset.mint.toBase58()
|
|
)}
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Input.Group>
|
|
);
|
|
}
|
|
|
|
function MintSelector({ label, style, value, onChange }) {
|
|
const mintToTickers = useMintToTickers();
|
|
const options = useMemo(() => {
|
|
return Object.entries(mintToTickers)
|
|
.filter(
|
|
([mintAddress, ticker]) =>
|
|
mintAddress.includes(value) ||
|
|
ticker.toLowerCase().includes(value.toLowerCase()),
|
|
)
|
|
.map(([mintAddress, ticker]) => ({
|
|
value: mintAddress,
|
|
label: (
|
|
<>
|
|
{ticker} ({mintAddress})
|
|
</>
|
|
),
|
|
}));
|
|
}, [mintToTickers, value]);
|
|
return (
|
|
<Input.Group style={style}>
|
|
<span className="ant-input-group-addon">{label}</span>
|
|
<AutoComplete
|
|
options={options}
|
|
value={value}
|
|
onChange={(e) => onChange(e)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Input.Group>
|
|
);
|
|
}
|