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 (
-
- 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 (
View on Solana Explorer
@@ -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 {