diff --git a/package.json b/package.json index 39c8f3c..32413dd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "bip32": "^2.0.5", "bip39": "^3.0.2", "bn.js": "^5.1.2", + "bs58": "^4.0.1", "buffer-layout": "^1.2.0", "immutable-tuple": "^0.4.10", "notistack": "^0.9.17", diff --git a/src/App.js b/src/App.js index fb1af7a..2469668 100644 --- a/src/App.js +++ b/src/App.js @@ -31,17 +31,17 @@ function App() { }> - - - + + + }> - - - + + + ); diff --git a/src/WalletPage.js b/src/WalletPage.js index 06b53d9..16958c2 100644 --- a/src/WalletPage.js +++ b/src/WalletPage.js @@ -2,13 +2,13 @@ import React from 'react'; import Container from '@material-ui/core/Container'; import BalancesList from './components/BalancesList'; 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 { createAndInitializeMint } from './utils/tokens'; import Grid from '@material-ui/core/Grid'; import { refreshAccountInfo, useIsProdNetwork } from './utils/connection'; import { useUpdateTokenName } from './utils/tokens/names'; -import { sleep } from './utils/utils'; +import { abbreviateAddress, sleep } from './utils/utils'; import { useCallAsync, useSendTransaction } from './utils/notifications'; export default function WalletPage() { @@ -34,50 +34,52 @@ function DevnetButtons() { const updateTokenName = useUpdateTokenName(); const [sendTransaction, sending] = useSendTransaction(); const callAsync = useCallAsync(); + + function requestAirdrop() { + callAsync( + wallet.connection.requestAirdrop( + wallet.account.publicKey, + LAMPORTS_PER_SOL, + ), + { + onSuccess: async () => { + await sleep(1000); + refreshAccountInfo(wallet.connection, wallet.account.publicKey); + }, + }, + ); + } + + function mintTestToken() { + let mint = new Account(); + updateTokenName( + mint.publicKey, + `Test Token ${abbreviateAddress(mint.publicKey)}`, + `TEST${mint.publicKey.toBase58().slice(0, 2)}`, + ); + sendTransaction( + createAndInitializeMint({ + connection: wallet.connection, + payer: wallet.account, + mint, + amount: 1000, + decimals: 2, + initialAccount: new Account(), + mintOwner: wallet.account, + }), + { onSuccess: () => refreshWalletPublicKeys(wallet) }, + ); + } + return (
- - diff --git a/src/components/BalancesList.js b/src/components/BalancesList.js index 01f56f0..2b7ff24 100644 --- a/src/components/BalancesList.js +++ b/src/components/BalancesList.js @@ -4,9 +4,10 @@ import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import Paper from '@material-ui/core/Paper'; import { + refreshWalletPublicKeys, useBalanceInfo, useWallet, - useWalletAccountCount, + useWalletPublicKeys, } from '../utils/wallet'; import LoadingIndicator from './LoadingIndicator'; 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 AddTokenDialog from './AddTokenDialog'; import SendDialog from './SendDialog'; -import { refreshAccountInfo } from '../utils/connection'; +import { + refreshAccountInfo, + useSolanaExplorerUrlSuffix, +} from '../utils/connection'; const balanceFormat = new Intl.NumberFormat(undefined, { minimumFractionDigits: 4, @@ -37,7 +41,7 @@ const balanceFormat = new Intl.NumberFormat(undefined, { export default function BalancesList() { const wallet = useWallet(); - const accountCount = useWalletAccountCount(); + const [publicKeys, loaded] = useWalletPublicKeys(); const [showAddTokenDialog, setShowAddTokenDialog] = useState(false); return ( @@ -54,15 +58,12 @@ export default function BalancesList() { - [...Array(accountCount + 5).keys()].map((i) => - refreshAccountInfo( - wallet.connection, - wallet.getAccount(i).publicKey, - true, - ), - ) - } + onClick={() => { + refreshWalletPublicKeys(wallet); + publicKeys.map((publicKey) => + refreshAccountInfo(wallet.connection, publicKey, true), + ); + }} style={{ marginRight: -12 }} > @@ -71,9 +72,10 @@ export default function BalancesList() { - {[...Array(accountCount + 5).keys()].map((i) => ( - + {publicKeys.map((publicKey) => ( + ))} + {loaded ? null : } ({ }, })); -function BalanceListItem({ index }) { - const wallet = useWallet(); - const accountCount = useWalletAccountCount(); - const balanceInfo = useBalanceInfo(index); - const [open, setOpen] = useState(false); +function BalanceListItem({ publicKey }) { + const balanceInfo = useBalanceInfo(publicKey); + const urlSuffix = useSolanaExplorerUrlSuffix(); const classes = useStyles(); + const [open, setOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false); if (!balanceInfo) { - if (index <= accountCount) { - return ; - } - return null; + return ; } - const account = wallet.getAccount(index); - let { - amount, - decimals, - mint, - tokenName, - tokenSymbol, - initialized, - } = balanceInfo; - - if (!initialized && index !== 0) { - return null; - } + let { amount, decimals, mint, tokenName, tokenSymbol } = balanceInfo; return ( <> @@ -140,7 +126,7 @@ function BalanceListItem({ index }) { {tokenSymbol ?? abbreviateAddress(mint)} } - secondary={account.publicKey.toBase58()} + secondary={publicKey.toBase58()} secondaryTypographyProps={{ className: classes.address }} /> {open ? : } @@ -165,7 +151,7 @@ function BalanceListItem({ index }) {
- Deposit Address: {account.publicKey.toBase58()} + Deposit Address: {publicKey.toBase58()} Token Name: {tokenName ?? 'Unknown'} @@ -180,7 +166,10 @@ function BalanceListItem({ index }) { ) : null} @@ -193,7 +182,6 @@ function BalanceListItem({ index }) { open={sendDialogOpen} onClose={() => setSendDialogOpen(false)} balanceInfo={balanceInfo} - index={index} /> ); diff --git a/src/components/LoadingIndicator.js b/src/components/LoadingIndicator.js index a37fded..fc12e04 100644 --- a/src/components/LoadingIndicator.js +++ b/src/components/LoadingIndicator.js @@ -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 [visible, setVisible] = useState(false); - useEffectAfterTimeout(() => setVisible(true), 500); + useEffectAfterTimeout(() => setVisible(true), delay); let style = {}; if (height) { diff --git a/src/components/SendDialog.js b/src/components/SendDialog.js index 2ea21b3..7f34727 100644 --- a/src/components/SendDialog.js +++ b/src/components/SendDialog.js @@ -11,7 +11,7 @@ import { abbreviateAddress } from '../utils/utils'; import InputAdornment from '@material-ui/core/InputAdornment'; 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 [destinationAddress, setDestinationAddress] = useState(''); const [transferAmountString, setTransferAmountString] = useState(''); @@ -33,7 +33,11 @@ export default function SendDialog({ open, onClose, index, balanceInfo }) { throw new Error('Invalid amount'); } sendTransaction( - wallet.transferToken(index, new PublicKey(destinationAddress), amount), + wallet.transferToken( + publicKey, + new PublicKey(destinationAddress), + amount, + ), { onSuccess: onClose }, ); } diff --git a/src/utils/connection.js b/src/utils/connection.js index 7f4675b..d94a91b 100644 --- a/src/utils/connection.js +++ b/src/utils/connection.js @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useMemo } from 'react'; import { clusterApiUrl, Connection } from '@solana/web3.js'; import { useLocalStorageState } from './utils'; -import { refreshCache, useAsyncData } from './fetch-loop'; +import { refreshCache, setCache, useAsyncData } from './fetch-loop'; import tuple from 'immutable-tuple'; const ConnectionContext = React.createContext(null); @@ -37,6 +37,16 @@ export function useIsProdNetwork() { 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) { const connection = useConnection(); const cacheKey = tuple(connection, publicKey?.toBase58()); @@ -61,3 +71,8 @@ export function refreshAccountInfo(connection, publicKey, clearCache = false) { const cacheKey = tuple(connection, publicKey.toBase58()); refreshCache(cacheKey, clearCache); } + +export function setInitialAccountInfo(connection, publicKey, accountInfo) { + const cacheKey = tuple(connection, publicKey.toBase58()); + setCache(cacheKey, accountInfo, { initializeOnly: true }); +} diff --git a/src/utils/fetch-loop.js b/src/utils/fetch-loop.js index f123e01..ff6e6f9 100644 --- a/src/utils/fetch-loop.js +++ b/src/utils/fetch-loop.js @@ -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(); + } +} diff --git a/src/utils/notifications.js b/src/utils/notifications.js index 2d417eb..167088e 100644 --- a/src/utils/notifications.js +++ b/src/utils/notifications.js @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useSnackbar } from 'notistack'; -import { useConnection } from './connection'; +import { useConnection, useSolanaExplorerUrlSuffix } from './connection'; import Button from '@material-ui/core/Button'; export function useSendTransaction() { @@ -8,7 +8,10 @@ export function useSendTransaction() { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const [sending, setSending] = useState(false); - async function sendTransaction(signaturePromise, { onSuccess, onError }) { + async function sendTransaction( + signaturePromise, + { onSuccess, onError } = {}, + ) { let id = enqueueSnackbar('Sending transaction...', { variant: 'info', persist: true, @@ -36,6 +39,7 @@ export function useSendTransaction() { } catch (e) { closeSnackbar(id); setSending(false); + console.warn(e.message); enqueueSnackbar(e.message, { variant: 'error' }); if (onError) { onError(e); @@ -47,13 +51,14 @@ export function useSendTransaction() { } function ViewTransactionOnExplorerButton({ signature }) { + const urlSuffix = useSolanaExplorerUrlSuffix(); return ( @@ -69,7 +74,7 @@ export function useCallAsync() { successMessage = 'Success', onSuccess, onError, - }, + } = {}, ) { let id = enqueueSnackbar(progressMessage, { variant: 'info', diff --git a/src/utils/tokens/data.js b/src/utils/tokens/data.js index 695d869..f94b003 100644 --- a/src/utils/tokens/data.js +++ b/src/utils/tokens/data.js @@ -27,3 +27,17 @@ export function parseMintData(data) { let { decimals } = MINT_LAYOUT.decode(data); return { decimals }; } + +export function getOwnedAccountsFilters(publicKey) { + return [ + { + memcmp: { + offset: ACCOUNT_LAYOUT.offsetOf('owner'), + bytes: publicKey.toBase58(), + }, + }, + { + dataSize: ACCOUNT_LAYOUT.span, + }, + ]; +} diff --git a/src/utils/tokens/index.js b/src/utils/tokens/index.js index 92454c4..66e0660 100644 --- a/src/utils/tokens/index.js +++ b/src/utils/tokens/index.js @@ -1,11 +1,58 @@ -import { SystemProgram, Transaction } from '@solana/web3.js'; +import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; import { initializeAccount, initializeMint, TOKEN_PROGRAM_ID, transfer, } 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({ connection, diff --git a/src/utils/tokens/names.js b/src/utils/tokens/names.js index 87fa03f..e8b0594 100644 --- a/src/utils/tokens/names.js +++ b/src/utils/tokens/names.js @@ -8,6 +8,7 @@ const customTokenNamesByNetwork = JSON.parse( ); const nameUpdated = new EventEmitter(); +nameUpdated.setMaxListeners(100); export function useTokenName(mint) { const { endpoint } = useConnectionConfig(); diff --git a/src/utils/wallet.js b/src/utils/wallet.js index 16c88d6..87f8df9 100644 --- a/src/utils/wallet.js +++ b/src/utils/wallet.js @@ -1,33 +1,33 @@ -import React, { useContext, useEffect, useMemo } from 'react'; +import React, { useContext, useMemo } from 'react'; import * as bip32 from 'bip32'; import { Account } from '@solana/web3.js'; import nacl from 'tweetnacl'; import { - refreshAccountInfo, + setInitialAccountInfo, useAccountInfo, useConnection, } from './connection'; -import { createAndInitializeTokenAccount, transferTokens } from './tokens'; +import { + createAndInitializeTokenAccount, + getOwnedTokenAccounts, + transferTokens, +} from './tokens'; import { TOKEN_PROGRAM_ID } from './tokens/instructions'; import { ACCOUNT_LAYOUT, parseMintData, parseTokenAccountData, } from './tokens/data'; -import EventEmitter from 'events'; -import { useListener, useLocalStorageState } from './utils'; +import { useLocalStorageState } from './utils'; import { useTokenName } from './tokens/names'; +import { refreshCache, useAsyncData } from './fetch-loop'; export class Wallet { constructor(connection, seed, walletIndex = 0) { this.connection = connection; this.seed = seed; this.walletIndex = walletIndex; - this.accountCount = 1; - this.account = this.getAccount(0); - - this.emitter = new EventEmitter(); - this.emitter.setMaxListeners(50); + this.account = Wallet.getAccountFromSeed(this.seed, this.walletIndex); } static getAccountFromSeed(seed, walletIndex, accountIndex = 0) { @@ -37,21 +37,24 @@ export class Wallet { return new Account(nacl.sign.keyPair.fromSeed(derivedSeed).secretKey); } - getAccount = (index) => { - return Wallet.getAccountFromSeed(this.seed, this.walletIndex, index); + getTokenPublicKeys = async () => { + let accounts = await getOwnedTokenAccounts( + this.connection, + this.account.publicKey, + ); + return accounts.map(({ publicKey, accountInfo }) => { + setInitialAccountInfo(this.connection, publicKey, accountInfo); + return publicKey; + }); }; createTokenAccount = async (tokenAddress) => { - let index = this.accountCount; - await createAndInitializeTokenAccount({ + return await createAndInitializeTokenAccount({ connection: this.connection, payer: this.account, 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 () => { @@ -60,12 +63,11 @@ export class Wallet { ); }; - transferToken = async (index, destination, amount) => { - let tokenAccount = this.getAccount(index); + transferToken = async (source, destination, amount) => { return await transferTokens({ connection: this.connection, owner: this.account, - sourcePublicKey: tokenAccount.publicKey, + sourcePublicKey: source, destinationPublicKey: destination, amount, }); @@ -103,15 +105,21 @@ export function useWallet() { return useContext(WalletContext).wallet; } -export function useWalletAccountCount() { +export function useWalletPublicKeys() { let wallet = useWallet(); - useListener(wallet.emitter, 'accountCountChange'); - return wallet.accountCount; + let [tokenPublicKeys, loaded] = useAsyncData( + wallet.getTokenPublicKeys, + wallet.getTokenPublicKeys, + ); + let publicKeys = [wallet.account.publicKey, ...(tokenPublicKeys ?? [])]; + return [publicKeys, loaded]; } -export function useBalanceInfo(index) { - let wallet = useWallet(); - let publicKey = wallet.getAccount(index).publicKey; +export function refreshWalletPublicKeys(wallet) { + refreshCache(wallet.getTokenPublicKeys); +} + +export function useBalanceInfo(publicKey) { let [accountInfo, accountInfoLoaded] = useAccountInfo(publicKey); let { mint, owner, amount } = accountInfo?.owner.equals(TOKEN_PROGRAM_ID) ? parseTokenAccountData(accountInfo.data) @@ -119,13 +127,6 @@ export function useBalanceInfo(index) { let [mintInfo, mintInfoLoaded] = useAccountInfo(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) { let { decimals } = parseMintData(mintInfo.data); return {