Add merge accounts dialog

This commit is contained in:
Armani Ferrante 2021-02-20 16:58:16 +08:00
parent 6fd634e498
commit 01d3aa715d
No known key found for this signature in database
GPG Key ID: D597A80BCF8E12B7
6 changed files with 562 additions and 5 deletions

View File

@ -31,6 +31,7 @@ import IconButton from '@material-ui/core/IconButton';
import InfoIcon from '@material-ui/icons/InfoOutlined';
import Tooltip from '@material-ui/core/Tooltip';
import EditIcon from '@material-ui/icons/Edit';
import MergeType from '@material-ui/icons/MergeType';
import { MARKETS } from '@project-serum/serum';
import AddTokenDialog from './AddTokenDialog';
import ExportAccountDialog from './ExportAccountDialog';
@ -50,6 +51,7 @@ import CloseTokenAccountDialog from './CloseTokenAccountButton';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import TokenIcon from './TokenIcon';
import EditAccountNameDialog from './EditAccountNameDialog';
import MergeAccountsDialog from './MergeAccountsDialog';
const balanceFormat = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 4,
@ -86,6 +88,7 @@ export default function BalancesList() {
const [showEditAccountNameDialog, setShowEditAccountNameDialog] = useState(
false,
);
const [showMergeAccounts, setShowMergeAccounts] = useState(false);
const { accounts, setAccountName } = useWalletSelector();
const selectedAccount = accounts.find((a) => a.isSelected);
@ -105,6 +108,11 @@ export default function BalancesList() {
</IconButton>
</Tooltip>
)}
<Tooltip title="Merge Accounts" arrow>
<IconButton onClick={() => setShowMergeAccounts(true)}>
<MergeType />
</IconButton>
</Tooltip>
<Tooltip title="Add Token" arrow>
<IconButton onClick={() => setShowAddTokenDialog(true)}>
<AddIcon />
@ -144,6 +152,10 @@ export default function BalancesList() {
setShowEditAccountNameDialog(false);
}}
/>
<MergeAccountsDialog
open={showMergeAccounts}
onClose={() => setShowMergeAccounts(false)}
/>
</Paper>
);
}

View File

@ -0,0 +1,297 @@
import { useState } from 'react';
import { useSnackbar } from 'notistack';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import DialogActions from '@material-ui/core/DialogActions';
import Link from '@material-ui/core/Link';
import TextField from '@material-ui/core/TextField';
import CircularProgress from '@material-ui/core/CircularProgress';
import { TokenInstructions } from '@project-serum/serum';
import { useWalletPublicKeys } from '../utils/wallet';
import {
useConnection,
refreshAccountInfo,
getMultipleSolanaAccounts,
} from '../utils/connection';
import { parseTokenAccountData } from '../utils/tokens/data';
import { refreshWalletPublicKeys, useWallet } from '../utils/wallet';
import {
createAssociatedTokenAccount,
findAssociatedTokenAddress,
} from '../utils/tokens';
import { sleep } from '../utils/utils';
import { getTokenName } from '../utils/tokens/names';
export default function MergeAccountsDialog({ open, onClose }) {
const [publicKeys] = useWalletPublicKeys();
const connection = useConnection();
const wallet = useWallet();
const { enqueueSnackbar } = useSnackbar();
const [isMerging, setIsMerging] = useState(false);
const [mergeCheck, setMergeCheck] = useState('');
// Merging accounts is a destructive operation that, for each mint,
//
// * Creates an associated token account, if not already created
// * Moves all funds into the associated token account
// * Closes every account, excluding the associated token account.
//
// Although it's ok if this operation fails--since the user can just
// retry again--it's not a good experience; hence the retry logic.
// The retry count of 30 is arbitrary and probably overly conservative.
const mergeAccounts = async (retryCount = 30) => {
try {
if (retryCount === 0) {
enqueueSnackbar(`Unable to complete merge. Please try again.`, {
variant: 'error',
});
return;
}
// Fetch all token accounts owned by the wallet. An account is null
// if we previously sent the close transaction, but did not receive
// a response due to RPC node instability.
const tokenAccounts = (
await getMultipleSolanaAccounts(connection, publicKeys)
)
.filter(
(acc) =>
acc !== null &&
acc.account.owner.equals(TokenInstructions.TOKEN_PROGRAM_ID),
)
.map(({ publicKey, account }) => {
return {
publicKey,
account: parseTokenAccountData(account.data),
owner: account.owner,
};
});
// Group the token accounts by mint.
const groupedTokenAccounts = {};
tokenAccounts.forEach((ta) => {
const key = ta.account.mint.toString();
if (groupedTokenAccounts[key]) {
groupedTokenAccounts[key].push(ta);
} else {
groupedTokenAccounts[key] = [ta];
}
});
// For each mint, merge them into one, associated token account.
const mints = Object.keys(groupedTokenAccounts);
for (let k = 0; k < mints.length; k += 1) {
const mintGroup = groupedTokenAccounts[mints[k]];
if (mintGroup.length > 0) {
const mint = mintGroup[0].account.mint;
const assocTokAddr = await findAssociatedTokenAddress(
wallet.publicKey,
mint,
);
// Don't merge if the only account is the associated token address.
if (
!(
mintGroup.length === 1 &&
assocTokAddr.equals(mintGroup[0].publicKey)
)
) {
const name = getTokenName(mint, connection._rpcEndpoint);
const symbol = name.symbol ? name.symbol : mint.toString();
console.log(`Merging ${symbol}`);
enqueueSnackbar(`Merging ${symbol}`, {
variant: 'info',
});
await mergeMint(
assocTokAddr,
mintGroup,
wallet,
connection,
enqueueSnackbar,
);
}
}
}
// Wait to give the RPC nodes some time to catch up.
await sleep(5000);
// Refresh the UI to remove any duplicates.
await refresh(wallet, publicKeys);
// Exit dialogue.
close();
} catch (err) {
console.error('There was a problem merging accounts', err);
enqueueSnackbar('Could not confirm transaction. Please wait.', {
variant: 'info',
});
// Sleep to give the RPC nodes some time to catch up.
await sleep(10000);
enqueueSnackbar('Retrying merge', { variant: 'info' });
await mergeAccounts(retryCount - 1);
}
};
const close = () => {
setMergeCheck('');
onClose();
};
const disabled = mergeCheck.toLowerCase() !== 'merge';
return (
<Dialog disableBackdropClick={isMerging} open={open} onClose={onClose}>
{isMerging ? (
<DialogContent>
<DialogContentText style={{ marginBottom: 0, textAlign: 'center' }}>
Merging Accounts
</DialogContentText>
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '24px',
}}
>
<CircularProgress />
</div>
</DialogContent>
) : (
<>
<DialogTitle>Are you sure you want to merge accounts?</DialogTitle>
<DialogContent>
<DialogContentText>
<b>WARNING</b>: This action may break apps that depend on your
existing accounts.
</DialogContentText>
<DialogContentText>
Merging sends all tokens to{' '}
<Link
href={'https://spl.solana.com/associated-token-account'}
target="_blank"
rel="noopener"
>
associated token accounts
</Link>
{', '}
deduplicating and closing any accounts that share the same mint.
If associated token accounts do not exist, then they will be
created.
</DialogContentText>
<DialogContentText>
If merging fails during a period of high network load, you will
not have lost your funds. Just recontinue the merge from where you
left off.
</DialogContentText>
<TextField
label={`Please type "merge" to confirm`}
fullWidth
variant="outlined"
margin="normal"
value={mergeCheck}
onChange={(e) => setMergeCheck(e.target.value.trim())}
/>
</DialogContent>
<DialogActions>
<Button onClick={close} color="primary">
Cancel
</Button>
<Button
disabled={disabled}
onClick={() => {
setIsMerging(true);
mergeAccounts()
.then(() => {
enqueueSnackbar('Account merge complete', {
variant: 'success',
});
setIsMerging(false);
})
.catch((err) => {
enqueueSnackbar(
`There was a problem merging your accounts: ${err.toString()}`,
{ variant: 'error' },
);
setIsMerging(false);
});
}}
color="secondary"
autoFocus
>
Merge
</Button>
</DialogActions>
</>
)}
</Dialog>
);
}
// Merges the given array of token accounts into one associated token account.
async function mergeMint(
assocTokAddr,
mintAccountSet,
wallet,
connection,
enqueueSnackbar,
) {
if (mintAccountSet.length === 0) {
return;
}
// Get the associated token account.
let associatedTokenAccount = await (async () => {
let assocTok = mintAccountSet
.map((assocTok) => assocTok.publicKey)
.filter((tokAddr) => tokAddr.equals(assocTokAddr))
.pop();
// Do we already have the token account?
if (assocTok) {
return assocTok;
}
// Check if the associated token account has been created.
// This is required due to a sometimes unstable network, where
// the account is created, but the client doesn't receive a
// response confirmation.
const accInfo = await connection.getAccountInfo(assocTokAddr);
if (accInfo !== null) {
return assocTokAddr;
}
// If it doesn't exist, then make it.
const [address] = await createAssociatedTokenAccount({
connection,
wallet,
splTokenMintAddress: mintAccountSet[0].account.mint,
});
return address;
})();
// Send all funds to the associated token account for each account.
// Once the funds are transferred, close the duplicated account.
for (let k = 0; k < mintAccountSet.length; k += 1) {
const tokenAccount = mintAccountSet[k];
if (tokenAccount.publicKey.equals(associatedTokenAccount) === false) {
if (tokenAccount.account.amount > 0) {
await wallet.transferAndClose(
tokenAccount.publicKey,
associatedTokenAccount,
tokenAccount.account.amount,
);
} else {
await wallet.closeTokenAccount(tokenAccount.publicKey, true);
}
}
}
}
async function refresh(wallet, publicKeys) {
await refreshWalletPublicKeys(wallet);
publicKeys.map((publicKey) =>
refreshAccountInfo(wallet.connection, publicKey, true),
);
}

View File

@ -5,9 +5,11 @@ import {
Connection,
PublicKey,
} from '@solana/web3.js';
import tuple from 'immutable-tuple';
import { struct } from 'superstruct';
import assert from 'assert';
import { useLocalStorageState, useRefEqual } from './utils';
import { refreshCache, setCache, useAsyncData } from './fetch-loop';
import tuple from 'immutable-tuple';
const ConnectionContext = React.createContext<{
endpoint: string;
@ -120,3 +122,95 @@ export function setInitialAccountInfo(connection, publicKey, accountInfo) {
const cacheKey = tuple(connection, publicKey.toBase58());
setCache(cacheKey, accountInfo, { initializeOnly: true });
}
export async function getMultipleSolanaAccounts(
connection: Connection,
publicKeys: PublicKey[],
): Promise<
Array<null | { publicKey: PublicKey; account: AccountInfo<Buffer> }>
> {
const args = [publicKeys.map((k) => k.toBase58()), { commitment: 'recent' }];
// @ts-ignore
const unsafeRes = await connection._rpcRequest('getMultipleAccounts', args);
const res = GetMultipleAccountsAndContextRpcResult(unsafeRes);
if (res.error) {
throw new Error(
'failed to get info about accounts ' +
publicKeys.map((k) => k.toBase58()).join(', ') +
': ' +
res.error.message,
);
}
assert(typeof res.result !== 'undefined');
const accounts: Array<null | {
executable: any;
owner: PublicKey;
lamports: any;
data: Buffer;
}> = [];
for (const account of res.result.value) {
let value: {
executable: any;
owner: PublicKey;
lamports: any;
data: Buffer;
} | null = null;
if (res.result.value && account) {
const { executable, owner, lamports, data } = account;
assert(data[1] === 'base64');
value = {
executable,
owner: new PublicKey(owner),
lamports,
data: Buffer.from(data[0], 'base64'),
};
}
accounts.push(value);
}
return accounts.map((account, idx) => {
return account === null
? null
: {
publicKey: publicKeys[idx],
account,
};
});
}
function jsonRpcResult(resultDescription: any) {
const jsonRpcVersion = struct.literal('2.0');
return struct.union([
struct({
jsonrpc: jsonRpcVersion,
id: 'string',
error: 'any',
}),
struct({
jsonrpc: jsonRpcVersion,
id: 'string',
error: 'null?',
result: resultDescription,
}),
]);
}
function jsonRpcResultAndContext(resultDescription: any) {
return jsonRpcResult({
context: struct({
slot: 'number',
}),
value: resultDescription,
});
}
const AccountInfoResult = struct({
executable: 'boolean',
owner: 'string',
lamports: 'number',
data: 'any',
rentEpoch: 'number?',
});
export const GetMultipleAccountsAndContextRpcResult = jsonRpcResultAndContext(
struct.array([struct.union(['null', AccountInfoResult])]),
);

View File

@ -3,7 +3,10 @@ import {
SystemProgram,
Transaction,
Account,
TransactionInstruction,
SYSVAR_RENT_PUBKEY,
} from '@solana/web3.js';
import { TokenInstructions } from '@project-serum/serum';
import {
assertOwner,
closeAccount,
@ -73,6 +76,7 @@ export async function signAndSendTransaction(
transaction,
wallet,
signers,
skipPreflight = false,
) {
transaction.recentBlockhash = (
await connection.getRecentBlockhash('max')
@ -90,6 +94,7 @@ export async function signAndSendTransaction(
transaction = await wallet.signTransaction(transaction);
const rawTransaction = transaction.serialize();
return await connection.sendRawTransaction(rawTransaction, {
skipPreflight,
preflightCommitment: 'single',
});
}
@ -196,6 +201,98 @@ export async function createAndInitializeTokenAccount({
return await signAndSendTransaction(connection, transaction, payer, signers);
}
export async function createAssociatedTokenAccount({
connection,
wallet,
splTokenMintAddress,
}) {
const [ix, address] = await createAssociatedTokenAccountIx(
wallet.publicKey,
wallet.publicKey,
splTokenMintAddress,
);
const tx = new Transaction();
tx.add(ix);
tx.feePayer = wallet.publicKey;
const txSig = await signAndSendTransaction(connection, tx, wallet, []);
return [address, txSig];
}
async function createAssociatedTokenAccountIx(
fundingAddress,
walletAddress,
splTokenMintAddress,
) {
const associatedTokenAddress = await findAssociatedTokenAddress(
walletAddress,
splTokenMintAddress,
);
const systemProgramId = new PublicKey('11111111111111111111111111111111');
const keys = [
{
pubkey: fundingAddress,
isSigner: true,
isWritable: true,
},
{
pubkey: associatedTokenAddress,
isSigner: false,
isWritable: true,
},
{
pubkey: walletAddress,
isSigner: false,
isWritable: false,
},
{
pubkey: splTokenMintAddress,
isSigner: false,
isWritable: false,
},
{
pubkey: systemProgramId,
isSigner: false,
isWritable: false,
},
{
pubkey: TokenInstructions.TOKEN_PROGRAM_ID,
isSigner: false,
isWritable: false,
},
{
pubkey: SYSVAR_RENT_PUBKEY,
isSigner: false,
isWritable: false,
},
];
const ix = new TransactionInstruction({
keys,
programId: ASSOCIATED_TOKEN_PROGRAM_ID,
data: Buffer.from([]),
});
return [ix, associatedTokenAddress];
}
export async function findAssociatedTokenAddress(
walletAddress,
tokenMintAddress,
) {
return (
await PublicKey.findProgramAddress(
[
walletAddress.toBuffer(),
TokenInstructions.TOKEN_PROGRAM_ID.toBuffer(),
tokenMintAddress.toBuffer(),
],
ASSOCIATED_TOKEN_PROGRAM_ID,
)
)[0];
}
export const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey(
'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL',
);
export async function transferTokens({
connection,
owner,
@ -223,7 +320,10 @@ export async function transferTokens({
});
}
if ((!destinationAccountInfo || destinationAccountInfo.lamports === 0) && !overrideDestinationCheck) {
if (
(!destinationAccountInfo || destinationAccountInfo.lamports === 0) &&
!overrideDestinationCheck
) {
throw new Error('Cannot send to address with zero SOL balances');
}
const destinationSplTokenAccount = (
@ -257,6 +357,31 @@ export async function transferTokens({
});
}
// SPL tokens only.
export async function transferAndClose({
connection,
owner,
sourcePublicKey,
destinationPublicKey,
amount,
}) {
const tx = createTransferBetweenSplTokenAccountsInstruction({
ownerPublicKey: owner.publicKey,
sourcePublicKey,
destinationPublicKey,
amount,
});
tx.add(
closeAccount({
source: sourcePublicKey,
destination: owner.publicKey,
owner: owner.publicKey,
}),
);
let signers = [];
return await signAndSendTransaction(connection, tx, owner, signers);
}
function createTransferBetweenSplTokenAccountsInstruction({
ownerPublicKey,
sourcePublicKey,
@ -350,6 +475,7 @@ export async function closeTokenAccount({
connection,
owner,
sourcePublicKey,
skipPreflight,
}) {
let transaction = new Transaction().add(
closeAccount({
@ -359,5 +485,11 @@ export async function closeTokenAccount({
}),
);
let signers = [];
return await signAndSendTransaction(connection, transaction, owner, signers);
return await signAndSendTransaction(
connection,
transaction,
owner,
signers,
skipPreflight,
);
}

View File

@ -215,7 +215,10 @@ nameUpdated.setMaxListeners(100);
export function useTokenName(mint) {
const { endpoint } = useConnectionConfig();
useListener(nameUpdated, 'update');
return getTokenName(mint, endpoint);
}
export function getTokenName(mint, endpoint) {
if (!mint) {
return { name: null, symbol: null };
}

View File

@ -13,6 +13,7 @@ import {
getOwnedTokenAccounts,
nativeTransfer,
transferTokens,
transferAndClose,
} from './tokens';
import { TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT } from './tokens/instructions';
import {
@ -84,7 +85,14 @@ export class Wallet {
);
};
transferToken = async (source, destination, amount, mint, memo = null, overrideDestinationCheck = false) => {
transferToken = async (
source,
destination,
amount,
mint,
memo = null,
overrideDestinationCheck = false,
) => {
if (source.equals(this.publicKey)) {
if (memo) {
throw new Error('Memo not implemented');
@ -107,11 +115,22 @@ export class Wallet {
return nativeTransfer(this.connection, this, destination, amount);
};
closeTokenAccount = async (publicKey) => {
closeTokenAccount = async (publicKey, skipPreflight = false) => {
return await closeTokenAccount({
connection: this.connection,
owner: this,
sourcePublicKey: publicKey,
skipPreflight,
});
};
transferAndClose = async (source, destination, amount) => {
return await transferAndClose({
connection: this.connection,
owner: this,
sourcePublicKey: source,
destinationPublicKey: destination,
amount,
});
};