Switch to using getProgramAccounts for finding owned accounts

Use getProgramAccounts instead of relying on deterministically
generated keys so we don't need private keys to find accounts
This commit is contained in:
Gary Wang 2020-08-03 03:20:32 -07:00
parent 5e1858be1f
commit 5c5d163183
14 changed files with 236 additions and 145 deletions

View File

@ -12,6 +12,7 @@
"bip32": "^2.0.5", "bip32": "^2.0.5",
"bip39": "^3.0.2", "bip39": "^3.0.2",
"bn.js": "^5.1.2", "bn.js": "^5.1.2",
"bs58": "^4.0.1",
"buffer-layout": "^1.2.0", "buffer-layout": "^1.2.0",
"immutable-tuple": "^0.4.10", "immutable-tuple": "^0.4.10",
"notistack": "^0.9.17", "notistack": "^0.9.17",

View File

@ -31,17 +31,17 @@ function App() {
<Suspense fallback={<LoadingIndicator />}> <Suspense fallback={<LoadingIndicator />}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<SnackbarProvider maxSnack={5}>
<ConnectionProvider> <ConnectionProvider>
<WalletProvider> <WalletProvider>
<SnackbarProvider maxSnack={5}>
<NavigationFrame> <NavigationFrame>
<Suspense fallback={<LoadingIndicator />}> <Suspense fallback={<LoadingIndicator />}>
<WalletPage /> <WalletPage />
</Suspense> </Suspense>
</NavigationFrame> </NavigationFrame>
</SnackbarProvider>
</WalletProvider> </WalletProvider>
</ConnectionProvider> </ConnectionProvider>
</SnackbarProvider>
</ThemeProvider> </ThemeProvider>
</Suspense> </Suspense>
); );

View File

@ -2,13 +2,13 @@ import React from 'react';
import Container from '@material-ui/core/Container'; import Container from '@material-ui/core/Container';
import BalancesList from './components/BalancesList'; import BalancesList from './components/BalancesList';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import { useWallet } from './utils/wallet'; import { refreshWalletPublicKeys, useWallet } from './utils/wallet';
import { Account, LAMPORTS_PER_SOL } from '@solana/web3.js'; import { Account, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { createAndInitializeMint } from './utils/tokens'; import { createAndInitializeMint } from './utils/tokens';
import Grid from '@material-ui/core/Grid'; import Grid from '@material-ui/core/Grid';
import { refreshAccountInfo, useIsProdNetwork } from './utils/connection'; import { refreshAccountInfo, useIsProdNetwork } from './utils/connection';
import { useUpdateTokenName } from './utils/tokens/names'; import { useUpdateTokenName } from './utils/tokens/names';
import { sleep } from './utils/utils'; import { abbreviateAddress, sleep } from './utils/utils';
import { useCallAsync, useSendTransaction } from './utils/notifications'; import { useCallAsync, useSendTransaction } from './utils/notifications';
export default function WalletPage() { export default function WalletPage() {
@ -34,12 +34,8 @@ function DevnetButtons() {
const updateTokenName = useUpdateTokenName(); const updateTokenName = useUpdateTokenName();
const [sendTransaction, sending] = useSendTransaction(); const [sendTransaction, sending] = useSendTransaction();
const callAsync = useCallAsync(); const callAsync = useCallAsync();
return (
<div style={{ display: 'flex' }}> function requestAirdrop() {
<Button
variant="contained"
color="primary"
onClick={() => {
callAsync( callAsync(
wallet.connection.requestAirdrop( wallet.connection.requestAirdrop(
wallet.account.publicKey, wallet.account.publicKey,
@ -52,19 +48,14 @@ function DevnetButtons() {
}, },
}, },
); );
}} }
>
Request Airdrop function mintTestToken() {
</Button>
<Button
variant="contained"
color="primary"
onClick={() => {
let mint = new Account(); let mint = new Account();
updateTokenName( updateTokenName(
mint.publicKey, mint.publicKey,
`Test Token ${mint.publicKey.toBase58().slice(0, 4)}`, `Test Token ${abbreviateAddress(mint.publicKey)}`,
`TEST${mint.publicKey.toBase58().slice(0, 4)}`, `TEST${mint.publicKey.toBase58().slice(0, 2)}`,
); );
sendTransaction( sendTransaction(
createAndInitializeMint({ createAndInitializeMint({
@ -73,11 +64,22 @@ function DevnetButtons() {
mint, mint,
amount: 1000, amount: 1000,
decimals: 2, decimals: 2,
initialAccount: wallet.getAccount(wallet.accountCount), initialAccount: new Account(),
mintOwner: wallet.account, mintOwner: wallet.account,
}), }),
{ onSuccess: () => refreshWalletPublicKeys(wallet) },
); );
}} }
return (
<div style={{ display: 'flex' }}>
<Button variant="contained" color="primary" onClick={requestAirdrop}>
Request Airdrop
</Button>
<Button
variant="contained"
color="primary"
onClick={mintTestToken}
disabled={sending} disabled={sending}
style={{ marginLeft: 24 }} style={{ marginLeft: 24 }}
> >

View File

@ -6,11 +6,12 @@ import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from '@material-ui/core/DialogContent';
import TextField from '@material-ui/core/TextField'; import TextField from '@material-ui/core/TextField';
import DialogForm from './DialogForm'; import DialogForm from './DialogForm';
import { useWallet } from '../utils/wallet'; import { refreshWalletPublicKeys, useWallet } from '../utils/wallet';
import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
import { useUpdateTokenName } from '../utils/tokens/names'; import { useUpdateTokenName } from '../utils/tokens/names';
import { useAsyncData } from '../utils/fetch-loop'; import { useAsyncData } from '../utils/fetch-loop';
import LoadingIndicator from './LoadingIndicator'; import LoadingIndicator from './LoadingIndicator';
import { useSendTransaction } from '../utils/notifications';
const feeFormat = new Intl.NumberFormat(undefined, { const feeFormat = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 6, minimumFractionDigits: 6,
@ -28,20 +29,17 @@ export default function AddTokenDialog({ open, onClose }) {
let [mintAddress, setMintAddress] = useState(''); let [mintAddress, setMintAddress] = useState('');
let [tokenName, setTokenName] = useState(''); let [tokenName, setTokenName] = useState('');
let [tokenSymbol, setTokenSymbol] = useState(''); let [tokenSymbol, setTokenSymbol] = useState('');
let [submitting, setSubmitting] = useState(false); let [sendTransaction, sending] = useSendTransaction();
async function onSubmit() { function onSubmit() {
setSubmitting(true);
try {
let mint = new PublicKey(mintAddress); let mint = new PublicKey(mintAddress);
await wallet.createTokenAccount(mint); sendTransaction(wallet.createTokenAccount(mint), {
onSuccess: () => {
updateTokenName(mint, tokenName, tokenSymbol); updateTokenName(mint, tokenName, tokenSymbol);
refreshWalletPublicKeys(wallet);
onClose(); onClose();
} catch (e) { },
console.warn(e); });
} finally {
setSubmitting(false);
}
} }
return ( return (
@ -83,7 +81,7 @@ export default function AddTokenDialog({ open, onClose }) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button type="submit" color="primary" disabled={submitting}> <Button type="submit" color="primary" disabled={sending}>
Add Add
</Button> </Button>
</DialogActions> </DialogActions>

View File

@ -4,9 +4,10 @@ import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText'; import ListItemText from '@material-ui/core/ListItemText';
import Paper from '@material-ui/core/Paper'; import Paper from '@material-ui/core/Paper';
import { import {
refreshWalletPublicKeys,
useBalanceInfo, useBalanceInfo,
useWallet, useWallet,
useWalletAccountCount, useWalletPublicKeys,
} from '../utils/wallet'; } from '../utils/wallet';
import LoadingIndicator from './LoadingIndicator'; import LoadingIndicator from './LoadingIndicator';
import Collapse from '@material-ui/core/Collapse'; import Collapse from '@material-ui/core/Collapse';
@ -27,7 +28,10 @@ import IconButton from '@material-ui/core/IconButton';
import Tooltip from '@material-ui/core/Tooltip'; import Tooltip from '@material-ui/core/Tooltip';
import AddTokenDialog from './AddTokenDialog'; import AddTokenDialog from './AddTokenDialog';
import SendDialog from './SendDialog'; import SendDialog from './SendDialog';
import { refreshAccountInfo } from '../utils/connection'; import {
refreshAccountInfo,
useSolanaExplorerUrlSuffix,
} from '../utils/connection';
const balanceFormat = new Intl.NumberFormat(undefined, { const balanceFormat = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 4, minimumFractionDigits: 4,
@ -37,7 +41,7 @@ const balanceFormat = new Intl.NumberFormat(undefined, {
export default function BalancesList() { export default function BalancesList() {
const wallet = useWallet(); const wallet = useWallet();
const accountCount = useWalletAccountCount(); const [publicKeys, loaded] = useWalletPublicKeys();
const [showAddTokenDialog, setShowAddTokenDialog] = useState(false); const [showAddTokenDialog, setShowAddTokenDialog] = useState(false);
return ( return (
@ -54,15 +58,12 @@ export default function BalancesList() {
</Tooltip> </Tooltip>
<Tooltip title="Refresh" arrow> <Tooltip title="Refresh" arrow>
<IconButton <IconButton
onClick={() => onClick={() => {
[...Array(accountCount + 5).keys()].map((i) => refreshWalletPublicKeys(wallet);
refreshAccountInfo( publicKeys.map((publicKey) =>
wallet.connection, refreshAccountInfo(wallet.connection, publicKey, true),
wallet.getAccount(i).publicKey, );
true, }}
),
)
}
style={{ marginRight: -12 }} style={{ marginRight: -12 }}
> >
<RefreshIcon /> <RefreshIcon />
@ -71,9 +72,10 @@ export default function BalancesList() {
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<List disablePadding> <List disablePadding>
{[...Array(accountCount + 5).keys()].map((i) => ( {publicKeys.map((publicKey) => (
<BalanceListItem key={i} index={i} /> <BalanceListItem key={publicKey.toBase58()} publicKey={publicKey} />
))} ))}
{loaded ? null : <LoadingIndicator />}
</List> </List>
<AddTokenDialog <AddTokenDialog
open={showAddTokenDialog} open={showAddTokenDialog}
@ -101,34 +103,18 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
function BalanceListItem({ index }) { function BalanceListItem({ publicKey }) {
const wallet = useWallet(); const balanceInfo = useBalanceInfo(publicKey);
const accountCount = useWalletAccountCount(); const urlSuffix = useSolanaExplorerUrlSuffix();
const balanceInfo = useBalanceInfo(index);
const [open, setOpen] = useState(false);
const classes = useStyles(); const classes = useStyles();
const [open, setOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false);
if (!balanceInfo) { if (!balanceInfo) {
if (index <= accountCount) { return <LoadingIndicator delay={0} />;
return <LoadingIndicator />;
}
return null;
} }
const account = wallet.getAccount(index); let { amount, decimals, mint, tokenName, tokenSymbol } = balanceInfo;
let {
amount,
decimals,
mint,
tokenName,
tokenSymbol,
initialized,
} = balanceInfo;
if (!initialized && index !== 0) {
return null;
}
return ( return (
<> <>
@ -140,7 +126,7 @@ function BalanceListItem({ index }) {
{tokenSymbol ?? abbreviateAddress(mint)} {tokenSymbol ?? abbreviateAddress(mint)}
</> </>
} }
secondary={account.publicKey.toBase58()} secondary={publicKey.toBase58()}
secondaryTypographyProps={{ className: classes.address }} secondaryTypographyProps={{ className: classes.address }}
/> />
{open ? <ExpandLess /> : <ExpandMore />} {open ? <ExpandLess /> : <ExpandMore />}
@ -165,7 +151,7 @@ function BalanceListItem({ index }) {
</Button> </Button>
</div> </div>
<Typography variant="body2" className={classes.address}> <Typography variant="body2" className={classes.address}>
Deposit Address: {account.publicKey.toBase58()} Deposit Address: {publicKey.toBase58()}
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
Token Name: {tokenName ?? 'Unknown'} Token Name: {tokenName ?? 'Unknown'}
@ -180,7 +166,10 @@ function BalanceListItem({ index }) {
) : null} ) : null}
<Typography variant="body2"> <Typography variant="body2">
<Link <Link
href={`https://explorer.solana.com/account/${account.publicKey.toBase58()}`} href={
`https://explorer.solana.com/account/${publicKey.toBase58()}` +
urlSuffix
}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
> >
@ -193,7 +182,6 @@ function BalanceListItem({ index }) {
open={sendDialogOpen} open={sendDialogOpen}
onClose={() => setSendDialogOpen(false)} onClose={() => setSendDialogOpen(false)}
balanceInfo={balanceInfo} balanceInfo={balanceInfo}
index={index}
/> />
</> </>
); );

View File

@ -14,11 +14,15 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
export default function LoadingIndicator({ height = null, ...rest }) { export default function LoadingIndicator({
height = null,
delay = 500,
...rest
}) {
const classes = useStyles(); const classes = useStyles();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
useEffectAfterTimeout(() => setVisible(true), 500); useEffectAfterTimeout(() => setVisible(true), delay);
let style = {}; let style = {};
if (height) { if (height) {

View File

@ -11,7 +11,7 @@ import { abbreviateAddress } from '../utils/utils';
import InputAdornment from '@material-ui/core/InputAdornment'; import InputAdornment from '@material-ui/core/InputAdornment';
import { useSendTransaction } from '../utils/notifications'; import { useSendTransaction } from '../utils/notifications';
export default function SendDialog({ open, onClose, index, balanceInfo }) { export default function SendDialog({ open, onClose, publicKey, balanceInfo }) {
const wallet = useWallet(); const wallet = useWallet();
const [destinationAddress, setDestinationAddress] = useState(''); const [destinationAddress, setDestinationAddress] = useState('');
const [transferAmountString, setTransferAmountString] = useState(''); const [transferAmountString, setTransferAmountString] = useState('');
@ -33,7 +33,11 @@ export default function SendDialog({ open, onClose, index, balanceInfo }) {
throw new Error('Invalid amount'); throw new Error('Invalid amount');
} }
sendTransaction( sendTransaction(
wallet.transferToken(index, new PublicKey(destinationAddress), amount), wallet.transferToken(
publicKey,
new PublicKey(destinationAddress),
amount,
),
{ onSuccess: onClose }, { onSuccess: onClose },
); );
} }

View File

@ -1,7 +1,7 @@
import React, { useContext, useEffect, useMemo } from 'react'; import React, { useContext, useEffect, useMemo } from 'react';
import { clusterApiUrl, Connection } from '@solana/web3.js'; import { clusterApiUrl, Connection } from '@solana/web3.js';
import { useLocalStorageState } from './utils'; import { useLocalStorageState } from './utils';
import { refreshCache, useAsyncData } from './fetch-loop'; import { refreshCache, setCache, useAsyncData } from './fetch-loop';
import tuple from 'immutable-tuple'; import tuple from 'immutable-tuple';
const ConnectionContext = React.createContext(null); const ConnectionContext = React.createContext(null);
@ -37,6 +37,16 @@ export function useIsProdNetwork() {
return endpoint === clusterApiUrl('mainnet-beta'); return endpoint === clusterApiUrl('mainnet-beta');
} }
export function useSolanaExplorerUrlSuffix() {
const endpoint = useContext(ConnectionContext).endpoint;
if (endpoint === clusterApiUrl('devnet')) {
return '?cluster=devnet';
} else if (endpoint === clusterApiUrl('testnet')) {
return '?cluster=testnet';
}
return '';
}
export function useAccountInfo(publicKey) { export function useAccountInfo(publicKey) {
const connection = useConnection(); const connection = useConnection();
const cacheKey = tuple(connection, publicKey?.toBase58()); const cacheKey = tuple(connection, publicKey?.toBase58());
@ -61,3 +71,8 @@ export function refreshAccountInfo(connection, publicKey, clearCache = false) {
const cacheKey = tuple(connection, publicKey.toBase58()); const cacheKey = tuple(connection, publicKey.toBase58());
refreshCache(cacheKey, clearCache); refreshCache(cacheKey, clearCache);
} }
export function setInitialAccountInfo(connection, publicKey, accountInfo) {
const cacheKey = tuple(connection, publicKey.toBase58());
setCache(cacheKey, accountInfo, { initializeOnly: true });
}

View File

@ -180,3 +180,14 @@ export function refreshCache(cacheKey, clearCache = false) {
} }
} }
} }
export function setCache(cacheKey, value, { initializeOnly = false } = {}) {
if (!initializeOnly && globalCache.has(cacheKey)) {
return;
}
globalCache.set(cacheKey, value);
const loop = globalLoops.loops.get(cacheKey);
if (loop) {
loop.notifyListeners();
}
}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { useConnection } from './connection'; import { useConnection, useSolanaExplorerUrlSuffix } from './connection';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
export function useSendTransaction() { export function useSendTransaction() {
@ -8,7 +8,10 @@ export function useSendTransaction() {
const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
async function sendTransaction(signaturePromise, { onSuccess, onError }) { async function sendTransaction(
signaturePromise,
{ onSuccess, onError } = {},
) {
let id = enqueueSnackbar('Sending transaction...', { let id = enqueueSnackbar('Sending transaction...', {
variant: 'info', variant: 'info',
persist: true, persist: true,
@ -36,6 +39,7 @@ export function useSendTransaction() {
} catch (e) { } catch (e) {
closeSnackbar(id); closeSnackbar(id);
setSending(false); setSending(false);
console.warn(e.message);
enqueueSnackbar(e.message, { variant: 'error' }); enqueueSnackbar(e.message, { variant: 'error' });
if (onError) { if (onError) {
onError(e); onError(e);
@ -47,13 +51,14 @@ export function useSendTransaction() {
} }
function ViewTransactionOnExplorerButton({ signature }) { function ViewTransactionOnExplorerButton({ signature }) {
const urlSuffix = useSolanaExplorerUrlSuffix();
return ( return (
<Button <Button
color="inherit" color="inherit"
component="a" component="a"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
href={`https://explorer.solana.com/tx/${signature}`} href={`https://explorer.solana.com/tx/${signature}` + urlSuffix}
> >
View on Solana Explorer View on Solana Explorer
</Button> </Button>
@ -69,7 +74,7 @@ export function useCallAsync() {
successMessage = 'Success', successMessage = 'Success',
onSuccess, onSuccess,
onError, onError,
}, } = {},
) { ) {
let id = enqueueSnackbar(progressMessage, { let id = enqueueSnackbar(progressMessage, {
variant: 'info', variant: 'info',

View File

@ -27,3 +27,17 @@ export function parseMintData(data) {
let { decimals } = MINT_LAYOUT.decode(data); let { decimals } = MINT_LAYOUT.decode(data);
return { decimals }; return { decimals };
} }
export function getOwnedAccountsFilters(publicKey) {
return [
{
memcmp: {
offset: ACCOUNT_LAYOUT.offsetOf('owner'),
bytes: publicKey.toBase58(),
},
},
{
dataSize: ACCOUNT_LAYOUT.span,
},
];
}

View File

@ -1,11 +1,58 @@
import { SystemProgram, Transaction } from '@solana/web3.js'; import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import { import {
initializeAccount, initializeAccount,
initializeMint, initializeMint,
TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID,
transfer, transfer,
} from './instructions'; } from './instructions';
import { ACCOUNT_LAYOUT, MINT_LAYOUT } from './data'; import { ACCOUNT_LAYOUT, getOwnedAccountsFilters, MINT_LAYOUT } from './data';
import bs58 from 'bs58';
export async function getOwnedTokenAccounts(connection, publicKey) {
let filters = getOwnedAccountsFilters(publicKey);
let resp = await connection._rpcRequest('getProgramAccounts', [
TOKEN_PROGRAM_ID.toBase58(),
{
commitment: connection.commitment,
filters,
},
]);
if (resp.error) {
throw new Error(
'failed to get token accounts owned by ' +
publicKey.toBase58() +
': ' +
resp.error.message,
);
}
return resp.result
.map(({ pubkey, account: { data, executable, owner, lamports } }) => ({
publicKey: new PublicKey(pubkey),
accountInfo: {
data: bs58.decode(data),
executable,
owner: new PublicKey(owner),
lamports,
},
}))
.filter(({ accountInfo }) => {
// TODO: remove this check once mainnet is updated
return filters.every((filter) => {
if (filter.dataSize) {
return accountInfo.data.length === filter.dataSize;
} else if (filter.memcmp) {
let filterBytes = bs58.decode(filter.memcmp.bytes);
return accountInfo.data
.slice(
filter.memcmp.offset,
filter.memcmp.offset + filterBytes.length,
)
.equals(filterBytes);
}
return false;
});
});
}
export async function createAndInitializeMint({ export async function createAndInitializeMint({
connection, connection,

View File

@ -8,6 +8,7 @@ const customTokenNamesByNetwork = JSON.parse(
); );
const nameUpdated = new EventEmitter(); const nameUpdated = new EventEmitter();
nameUpdated.setMaxListeners(100);
export function useTokenName(mint) { export function useTokenName(mint) {
const { endpoint } = useConnectionConfig(); const { endpoint } = useConnectionConfig();

View File

@ -1,33 +1,33 @@
import React, { useContext, useEffect, useMemo } from 'react'; import React, { useContext, useMemo } from 'react';
import * as bip32 from 'bip32'; import * as bip32 from 'bip32';
import { Account } from '@solana/web3.js'; import { Account } from '@solana/web3.js';
import nacl from 'tweetnacl'; import nacl from 'tweetnacl';
import { import {
refreshAccountInfo, setInitialAccountInfo,
useAccountInfo, useAccountInfo,
useConnection, useConnection,
} from './connection'; } from './connection';
import { createAndInitializeTokenAccount, transferTokens } from './tokens'; import {
createAndInitializeTokenAccount,
getOwnedTokenAccounts,
transferTokens,
} from './tokens';
import { TOKEN_PROGRAM_ID } from './tokens/instructions'; import { TOKEN_PROGRAM_ID } from './tokens/instructions';
import { import {
ACCOUNT_LAYOUT, ACCOUNT_LAYOUT,
parseMintData, parseMintData,
parseTokenAccountData, parseTokenAccountData,
} from './tokens/data'; } from './tokens/data';
import EventEmitter from 'events'; import { useLocalStorageState } from './utils';
import { useListener, useLocalStorageState } from './utils';
import { useTokenName } from './tokens/names'; import { useTokenName } from './tokens/names';
import { refreshCache, useAsyncData } from './fetch-loop';
export class Wallet { export class Wallet {
constructor(connection, seed, walletIndex = 0) { constructor(connection, seed, walletIndex = 0) {
this.connection = connection; this.connection = connection;
this.seed = seed; this.seed = seed;
this.walletIndex = walletIndex; this.walletIndex = walletIndex;
this.accountCount = 1; this.account = Wallet.getAccountFromSeed(this.seed, this.walletIndex);
this.account = this.getAccount(0);
this.emitter = new EventEmitter();
this.emitter.setMaxListeners(50);
} }
static getAccountFromSeed(seed, walletIndex, accountIndex = 0) { static getAccountFromSeed(seed, walletIndex, accountIndex = 0) {
@ -37,21 +37,24 @@ export class Wallet {
return new Account(nacl.sign.keyPair.fromSeed(derivedSeed).secretKey); return new Account(nacl.sign.keyPair.fromSeed(derivedSeed).secretKey);
} }
getAccount = (index) => { getTokenPublicKeys = async () => {
return Wallet.getAccountFromSeed(this.seed, this.walletIndex, index); let accounts = await getOwnedTokenAccounts(
this.connection,
this.account.publicKey,
);
return accounts.map(({ publicKey, accountInfo }) => {
setInitialAccountInfo(this.connection, publicKey, accountInfo);
return publicKey;
});
}; };
createTokenAccount = async (tokenAddress) => { createTokenAccount = async (tokenAddress) => {
let index = this.accountCount; return await createAndInitializeTokenAccount({
await createAndInitializeTokenAccount({
connection: this.connection, connection: this.connection,
payer: this.account, payer: this.account,
mintPublicKey: tokenAddress, mintPublicKey: tokenAddress,
newAccount: this.getAccount(index), newAccount: new Account(),
}); });
++this.accountCount;
refreshAccountInfo(this.connection, this.getAccount(index).publicKey, true);
this.emitter.emit('accountCountChange');
}; };
tokenAccountCost = async () => { tokenAccountCost = async () => {
@ -60,12 +63,11 @@ export class Wallet {
); );
}; };
transferToken = async (index, destination, amount) => { transferToken = async (source, destination, amount) => {
let tokenAccount = this.getAccount(index);
return await transferTokens({ return await transferTokens({
connection: this.connection, connection: this.connection,
owner: this.account, owner: this.account,
sourcePublicKey: tokenAccount.publicKey, sourcePublicKey: source,
destinationPublicKey: destination, destinationPublicKey: destination,
amount, amount,
}); });
@ -103,15 +105,21 @@ export function useWallet() {
return useContext(WalletContext).wallet; return useContext(WalletContext).wallet;
} }
export function useWalletAccountCount() { export function useWalletPublicKeys() {
let wallet = useWallet(); let wallet = useWallet();
useListener(wallet.emitter, 'accountCountChange'); let [tokenPublicKeys, loaded] = useAsyncData(
return wallet.accountCount; wallet.getTokenPublicKeys,
wallet.getTokenPublicKeys,
);
let publicKeys = [wallet.account.publicKey, ...(tokenPublicKeys ?? [])];
return [publicKeys, loaded];
} }
export function useBalanceInfo(index) { export function refreshWalletPublicKeys(wallet) {
let wallet = useWallet(); refreshCache(wallet.getTokenPublicKeys);
let publicKey = wallet.getAccount(index).publicKey; }
export function useBalanceInfo(publicKey) {
let [accountInfo, accountInfoLoaded] = useAccountInfo(publicKey); let [accountInfo, accountInfoLoaded] = useAccountInfo(publicKey);
let { mint, owner, amount } = accountInfo?.owner.equals(TOKEN_PROGRAM_ID) let { mint, owner, amount } = accountInfo?.owner.equals(TOKEN_PROGRAM_ID)
? parseTokenAccountData(accountInfo.data) ? parseTokenAccountData(accountInfo.data)
@ -119,13 +127,6 @@ export function useBalanceInfo(index) {
let [mintInfo, mintInfoLoaded] = useAccountInfo(mint); let [mintInfo, mintInfoLoaded] = useAccountInfo(mint);
let { name, symbol } = useTokenName(mint); let { name, symbol } = useTokenName(mint);
useEffect(() => {
if (accountInfo && wallet.accountCount < index + 1) {
wallet.accountCount = index + 1;
wallet.emitter.emit('accountCountChange');
}
}, [wallet, accountInfo, index]);
if (accountInfoLoaded && mint && mintInfoLoaded) { if (accountInfoLoaded && mint && mintInfoLoaded) {
let { decimals } = parseMintData(mintInfo.data); let { decimals } = parseMintData(mintInfo.data);
return { return {