Use @solana/spl-token-registry (#141)

This commit is contained in:
Pierre 2021-03-18 10:56:16 +11:00 committed by GitHub
parent 324b98d6ed
commit 4a601d8355
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 188 additions and 164 deletions

View File

@ -7,6 +7,7 @@
"@material-ui/core": "^4.11.2",
"@material-ui/icons": "^4.11.2",
"@project-serum/serum": "^0.13.24",
"@solana/spl-token-registry": "^0.2.1",
"@solana/web3.js": "^0.87.2",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",

View File

@ -11,6 +11,7 @@ import { ConnectionProvider } from './utils/connection';
import WalletPage from './pages/WalletPage';
import { useWallet, WalletProvider } from './utils/wallet';
import { ConnectedWalletsProvider } from './utils/connected-wallets';
import { TokenRegistryProvider } from './utils/tokens/names';
import LoadingIndicator from './components/LoadingIndicator';
import { SnackbarProvider } from 'notistack';
import PopupPage from './pages/PopupPage';
@ -62,9 +63,11 @@ export default function App() {
<CssBaseline />
<ConnectionProvider>
<SnackbarProvider maxSnack={5} autoHideDuration={8000}>
<WalletProvider>{appElement}</WalletProvider>
</SnackbarProvider>
<TokenRegistryProvider>
<SnackbarProvider maxSnack={5} autoHideDuration={8000}>
<WalletProvider>{appElement}</WalletProvider>
</SnackbarProvider>
</TokenRegistryProvider>
</ConnectionProvider>
</ThemeProvider>
</Suspense>

View File

@ -11,7 +11,7 @@ import {
useWalletTokenAccounts,
} from '../utils/wallet';
import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
import { TOKENS, useUpdateTokenName } from '../utils/tokens/names';
import { useUpdateTokenName, usePopularTokens } from '../utils/tokens/names';
import { useAsyncData } from '../utils/fetch-loop';
import LoadingIndicator from './LoadingIndicator';
import { makeStyles, Tab, Tabs } from '@material-ui/core';
@ -24,10 +24,7 @@ import { abbreviateAddress } from '../utils/utils';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import Collapse from '@material-ui/core/Collapse';
import {
useConnectionConfig,
useSolanaExplorerUrlSuffix,
} from '../utils/connection';
import { useSolanaExplorerUrlSuffix } from '../utils/connection';
import Link from '@material-ui/core/Link';
import CopyableDisplay from './CopyableDisplay';
import DialogForm from './DialogForm';
@ -56,10 +53,9 @@ export default function AddTokenDialog({ open, onClose }) {
let classes = useStyles();
let updateTokenName = useUpdateTokenName();
const [sendTransaction, sending] = useSendTransaction();
const { endpoint } = useConnectionConfig();
const popularTokens = TOKENS[endpoint];
const [walletAccounts] = useWalletTokenAccounts();
const [walletAccounts] = useWalletTokenAccounts();
const popularTokens = usePopularTokens();
const [tab, setTab] = useState(!!popularTokens ? 'popular' : 'manual');
const [mintAddress, setMintAddress] = useState('');
const [tokenName, setTokenName] = useState('');
@ -171,20 +167,18 @@ export default function AddTokenDialog({ open, onClose }) {
</React.Fragment>
) : tab === 'popular' ? (
<List disablePadding>
{popularTokens
.filter((token) => !token.deprecated)
.map((token) => (
<TokenListItem
key={token.mintAddress}
{...token}
existingAccount={(walletAccounts || []).find(
(account) =>
account.parsed.mint.toBase58() === token.mintAddress,
)}
onSubmit={onSubmit}
disalbed={sending}
/>
))}
{popularTokens.map((tokenInfo) => (
<TokenListItem
key={tokenInfo.address}
tokenInfo={tokenInfo}
existingAccount={(walletAccounts || []).find(
(account) =>
account.parsed.mint.toBase58() === tokenInfo.address,
)}
onSubmit={onSubmit}
disabled={sending}
/>
))}
</List>
) : tab === 'erc20' ? (
<>
@ -227,24 +221,21 @@ export default function AddTokenDialog({ open, onClose }) {
);
}
function TokenListItem({
tokenName,
icon,
tokenSymbol,
mintAddress,
onSubmit,
disabled,
existingAccount,
}) {
function TokenListItem({ tokenInfo, onSubmit, disabled, existingAccount }) {
const [open, setOpen] = useState(false);
const urlSuffix = useSolanaExplorerUrlSuffix();
const alreadyExists = !!existingAccount;
return (
<React.Fragment>
<div style={{ display: 'flex' }} key={tokenName}>
<div style={{ display: 'flex' }} key={tokenInfo.name}>
<ListItem button onClick={() => setOpen((open) => !open)}>
<ListItemIcon>
<TokenIcon url={icon} tokenName={tokenName} size={20} />
<TokenIcon
url={tokenInfo.logoUri}
tokenName={tokenInfo.name}
size={20}
/>
</ListItemIcon>
<ListItemText
primary={
@ -252,12 +243,12 @@ function TokenListItem({
target="_blank"
rel="noopener"
href={
`https://explorer.solana.com/account/${mintAddress}` +
`https://explorer.solana.com/account/${tokenInfo.address}` +
urlSuffix
}
>
{tokenName ?? abbreviateAddress(mintAddress)}
{tokenSymbol ? ` (${tokenSymbol})` : null}
{tokenInfo.name ?? abbreviateAddress(tokenInfo.address)}
{tokenInfo.symbol ? ` (${tokenInfo.symbol})` : null}
</Link>
}
/>
@ -267,15 +258,21 @@ function TokenListItem({
type="submit"
color="primary"
disabled={disabled || alreadyExists}
onClick={() => onSubmit({ tokenName, tokenSymbol, mintAddress })}
onClick={() =>
onSubmit({
tokenName: tokenInfo.name,
tokenSymbol: tokenInfo.symbol,
mintAddress: tokenInfo.address,
})
}
>
{alreadyExists ? 'Added' : 'Add'}
</Button>
</div>
<Collapse in={open} timeout="auto" unmountOnExit>
<CopyableDisplay
value={mintAddress}
label={`${tokenSymbol} Mint Address`}
value={tokenInfo.address}
label={`${tokenInfo.symbol} Mint Address`}
/>
</Collapse>
</React.Fragment>

View File

@ -363,7 +363,7 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
return <LoadingIndicator delay={0} />;
}
let { amount, decimals, mint, tokenName, tokenSymbol } = balanceInfo;
let { amount, decimals, mint, tokenName, tokenSymbol, tokenLogoUri } = balanceInfo;
tokenName = tokenName ?? abbreviateAddress(mint);
let displayName;
if (isExtensionWidth) {
@ -450,7 +450,7 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
<>
<ListItem button onClick={() => expandable && setOpen((open) => !open)}>
<ListItemIcon>
<TokenIcon mint={mint} tokenName={tokenName} size={28} />
<TokenIcon mint={mint} tokenName={tokenName} url={tokenLogoUri} size={28} />
</ListItemIcon>
<div style={{ display: 'flex', flex: 1 }}>
<ListItemText

View File

@ -24,7 +24,7 @@ import {
findAssociatedTokenAddress,
} from '../utils/tokens';
import { sleep } from '../utils/utils';
import { getTokenName } from '../utils/tokens/names';
import { useTokenInfos, getTokenInfo } from '../utils/tokens/names';
export default function MergeAccountsDialog({ open, onClose }) {
const [publicKeys] = useWalletPublicKeys();
@ -33,6 +33,7 @@ export default function MergeAccountsDialog({ open, onClose }) {
const { enqueueSnackbar } = useSnackbar();
const [isMerging, setIsMerging] = useState(false);
const [mergeCheck, setMergeCheck] = useState('');
const tokenInfos = useTokenInfos();
// Merging accounts is a destructive operation that, for each mint,
//
@ -98,8 +99,14 @@ export default function MergeAccountsDialog({ open, onClose }) {
assocTokAddr.equals(mintGroup[0].publicKey)
)
) {
const name = getTokenName(mint, connection._rpcEndpoint);
const symbol = name.symbol ? name.symbol : mint.toString();
const tokenInfo = getTokenInfo(
mint,
connection._rpcEndpoint,
tokenInfos,
);
const symbol = tokenInfo.symbol
? tokenInfo.symbol
: mint.toString();
console.log(`Merging ${symbol}`);
enqueueSnackbar(`Merging ${symbol}`, {
variant: 'info',

View File

@ -1,13 +1,13 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import Toolbar from '@material-ui/core/Toolbar';
import AppBar from '@material-ui/core/AppBar';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import { useConnectionConfig, MAINNET_URL } from '../utils/connection';
import { useConnectionConfig } from '../utils/connection';
import { CLUSTERS, clusterForEndpoint } from '../utils/clusters';
import Button from '@material-ui/core/Button';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import { clusterApiUrl } from '@solana/web3.js';
import { useWalletSelector } from '../utils/wallet';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import CheckIcon from '@material-ui/icons/Check';
@ -184,22 +184,10 @@ function ConnectionsButton() {
function NetworkSelector() {
const { endpoint, setEndpoint } = useConnectionConfig();
const cluster = useMemo(() => clusterForEndpoint(endpoint), [endpoint])
const [anchorEl, setAnchorEl] = useState(null);
const classes = useStyles();
const networks = [
MAINNET_URL,
clusterApiUrl('devnet'),
clusterApiUrl('testnet'),
'http://localhost:8899',
];
const networkLabels = {
[MAINNET_URL]: 'Mainnet Beta',
[clusterApiUrl('devnet')]: 'Devnet',
[clusterApiUrl('testnet')]: 'Testnet',
};
return (
<>
<Hidden xsDown>
@ -208,7 +196,7 @@ function NetworkSelector() {
onClick={(e) => setAnchorEl(e.target)}
className={classes.button}
>
{networkLabels[endpoint] ?? 'Network'}
{cluster?.label ?? 'Network'}
</Button>
</Hidden>
<Hidden smUp>
@ -228,19 +216,19 @@ function NetworkSelector() {
}}
getContentAnchorEl={null}
>
{networks.map((network) => (
{CLUSTERS.map((cluster) => (
<MenuItem
key={network}
key={cluster.apiUrl}
onClick={() => {
setAnchorEl(null);
setEndpoint(network);
setEndpoint(cluster.apiUrl);
}}
selected={network === endpoint}
selected={cluster.apiUrl === endpoint}
>
<ListItemIcon className={classes.menuItemIcon}>
{network === endpoint ? <CheckIcon fontSize="small" /> : null}
{cluster.apiUrl === endpoint ? <CheckIcon fontSize="small" /> : null}
</ListItemIcon>
{network}
{cluster.apiUrl}
</MenuItem>
))}
</Menu>

View File

@ -1,21 +1,10 @@
import { useConnectionConfig } from '../utils/connection';
import { TOKENS } from '../utils/tokens/names';
import React, { useState } from 'react';
export default function TokenIcon({ mint, url, tokenName, size = 20 }) {
const { endpoint } = useConnectionConfig();
const [hasError, setHasError] = useState(false);
if (!url) {
if (mint === null) {
url =
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/solana/info/logo.png';
} else {
url = TOKENS?.[endpoint]?.find(
(token) => token.mintAddress === mint?.toBase58(),
)?.icon;
}
if (!url && mint === null) {
url = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/solana/info/logo.png';
}
if (hasError || !url) {

29
src/utils/clusters.js Normal file
View File

@ -0,0 +1,29 @@
import { clusterApiUrl } from '@solana/web3.js';
import { MAINNET_URL } from '../utils/connection';
export const CLUSTERS = [
{
name: 'mainnet-beta',
apiUrl: MAINNET_URL,
label: 'Mainnet Beta'
},
{
name: 'devnet',
apiUrl: clusterApiUrl('devnet'),
label: 'Devnet'
},
{
name: 'testnet',
apiUrl: clusterApiUrl('testnet'),
label: 'Testnet'
},
{
name: 'localnet',
apiUrl: 'http://localhost:8899',
label: null
}
];
export function clusterForEndpoint(endpoint) {
return CLUSTERS.find(({ apiUrl }) => apiUrl === endpoint);
}

View File

@ -1,9 +1,20 @@
import React, { useContext, useState, useEffect } from 'react';
import EventEmitter from 'events';
import { useConnectionConfig, MAINNET_URL } from '../connection';
import { useListener } from '../utils';
import { clusterForEndpoint } from '../clusters';
import { useCallback } from 'react';
import { PublicKey } from '@solana/web3.js';
import { TokenListProvider } from '@solana/spl-token-registry';
export const TOKENS = {
// This list is used for deciding what to display in the popular tokens list
// in the `AddTokenDialog`.
//
// Icons, names, and symbols are fetched not from here, but from the
// @solana/spl-token-registry. To add an icon or token name to the wallet,
// add the mints to that package. To add a token to the `AddTokenDialog`,
// add the `mintAddress` here. The rest of the fields are not used.
const POPULAR_TOKENS = {
[MAINNET_URL]: [
{
mintAddress: 'SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt',
@ -214,62 +225,6 @@ export const TOKENS = {
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'RAY-LEGACY-USDT',
mintAddress: 'CzPDyvotTcxNqtPne32yUiEVQ6jk42HZi1Y3hUu7qf7f',
tokenName: 'Raydium Legacy USDT Liquidity Pool',
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'RAY-LEGACY-USDC',
mintAddress: 'FgmBnsF5Qrnv8X9bomQfEtQTQjNNiBCWRKGpzPnE5BDg',
tokenName: 'Raydium Legacy USDC Liquidity Pool',
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'RAY-LEGACY-SRM',
mintAddress: '5QXBMXuCL7zfAk39jEVVEvcrz1AvBGgT9wAhLLHLyyUJ',
tokenName: 'Raydium Legacy Serum Liquidity Pool',
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'RAY-ETH',
mintAddress: 'Q6MKy5Yxb9vG1mWzppMtMb2nrhNuCRNUkJTeiE3fuwD',
tokenName: 'Raydium ETH Liquidity Pool',
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'RAY-SOL',
mintAddress: 'F5PPQHGcznZ2FxD9JaxJMXaf7XkaFFJ6zzTBcW8osQjw',
tokenName: 'Raydium SOL Liquidity Pool',
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'RAY-SRM',
mintAddress: 'DSX5E21RE9FB9hM8Nh8xcXQfPK6SzRaJiywemHBSsfup',
tokenName: 'Raydium SRM Liquidity Pool',
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'RAY-USDT',
mintAddress: 'FdhKXYjCou2jQfgKWcNY7jb8F2DPLU1teTTTRfLBD2v1',
tokenName: 'Raydium USDT Liquidity Pool',
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'RAY-USDC',
mintAddress: 'BZFGfXMrjG2sS7QT2eiCDEevPFnkYYF7kzJpWfYxPbcx',
tokenName: 'Raydium USDC Liquidity Pool',
icon:
'https://raw.githubusercontent.com/raydium-io/media-assets/master/logo.svg',
},
{
tokenSymbol: 'OXY',
mintAddress: 'z3dn17yLaGMKffVogeFHQ9zWVcXgqgf3PQnDsNs2g6M',
@ -280,6 +235,39 @@ export const TOKENS = {
],
};
const TokenListContext = React.createContext({});
export function useTokenInfos() {
const { tokenInfos } = useContext(TokenListContext);
return tokenInfos;
}
export function TokenRegistryProvider(props) {
const { endpoint } = useConnectionConfig();
const [tokenInfos, setTokenInfos] = useState(null);
useEffect(() => {
const tokenListProvider = new TokenListProvider();
tokenListProvider.resolve().then((tokenListContainer) => {
const cluster = clusterForEndpoint(endpoint);
const filteredTokenListContainer = tokenListContainer?.filterByClusterSlug(
cluster?.name,
);
const tokenInfos =
tokenListContainer !== filteredTokenListContainer
? filteredTokenListContainer?.getList()
: null; // Workaround for filter return all on unknown slug
setTokenInfos(tokenInfos);
});
}, [endpoint]);
return (
<TokenListContext.Provider value={{ tokenInfos }}>
{props.children}
</TokenListContext.Provider>
);
}
const customTokenNamesByNetwork = JSON.parse(
localStorage.getItem('tokenNames') ?? '{}',
);
@ -287,25 +275,32 @@ const customTokenNamesByNetwork = JSON.parse(
const nameUpdated = new EventEmitter();
nameUpdated.setMaxListeners(100);
export function useTokenName(mint) {
export function useTokenInfo(mint) {
const { endpoint } = useConnectionConfig();
useListener(nameUpdated, 'update');
return getTokenName(mint, endpoint);
const tokenInfos = useTokenInfos();
return getTokenInfo(mint, endpoint, tokenInfos);
}
export function getTokenName(mint, endpoint) {
export function getTokenInfo(mint, endpoint, tokenInfos) {
if (!mint) {
return { name: null, symbol: null };
}
let info = customTokenNamesByNetwork?.[endpoint]?.[mint.toBase58()];
let match = TOKENS?.[endpoint]?.find(
(token) => token.mintAddress === mint.toBase58(),
let match = tokenInfos?.find(
(tokenInfo) => tokenInfo.address === mint.toBase58(),
);
if (match && (!info || match.deprecated)) {
info = { name: match.tokenName, symbol: match.tokenSymbol };
if (match) {
if (!info) {
info = { ...match, logoUri: match.logoURI };
}
// The user has overridden a name locally.
else {
info = { ...info, logoUri: match.logoURI };
}
}
return { name: info?.name, symbol: info?.symbol };
return { ...info };
}
export function useUpdateTokenName() {
@ -334,3 +329,14 @@ export function useUpdateTokenName() {
[endpoint],
);
}
// Returns tokenInfos for the popular tokens list.
export function usePopularTokens() {
const tokenInfos = useTokenInfos();
const { endpoint } = useConnectionConfig();
return (!POPULAR_TOKENS[endpoint]
? []
: POPULAR_TOKENS[endpoint]
).map((tok) =>
getTokenInfo(new PublicKey(tok.mintAddress), endpoint, tokenInfos),
);
}

View File

@ -16,14 +16,14 @@ import {
transferTokens,
transferAndClose,
} from './tokens';
import { TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT } from './tokens/instructions';
import { TOKEN_PROGRAM_ID } from './tokens/instructions';
import {
ACCOUNT_LAYOUT,
parseMintData,
parseTokenAccountData,
} from './tokens/data';
import { useListener, useLocalStorageState, useRefEqual } from './utils';
import { useTokenName } from './tokens/names';
import { useTokenInfo } from './tokens/names';
import { refreshCache, useAsyncData } from './fetch-loop';
import { getUnlockedMnemonicAndSeed, walletSeedChanged } from './wallet-seed';
import { WalletProviderFactory } from './walletProvider/factory';
@ -419,24 +419,12 @@ export function useBalanceInfo(publicKey) {
? parseTokenAccountData(accountInfo.data)
: {};
let [mintInfo, mintInfoLoaded] = useAccountInfo(mint);
let { name, symbol } = useTokenName(mint);
let { name, symbol, logoUri } = useTokenInfo(mint);
if (!accountInfoLoaded) {
return null;
}
if (mint && mint.equals(WRAPPED_SOL_MINT)) {
return {
amount,
decimals: 9,
mint,
owner,
tokenName: 'Wrapped SOL',
tokenSymbol: 'SOL',
valid: true,
};
}
if (mint && mintInfoLoaded) {
try {
let { decimals } = parseMintData(mintInfo.data);
@ -447,6 +435,7 @@ export function useBalanceInfo(publicKey) {
owner,
tokenName: name,
tokenSymbol: symbol,
tokenLogoUri: logoUri,
valid: true,
};
} catch (e) {
@ -457,6 +446,7 @@ export function useBalanceInfo(publicKey) {
owner,
tokenName: 'Invalid',
tokenSymbol: 'INVALID',
tokenLogoUri: null,
valid: false,
};
}

View File

@ -1918,6 +1918,13 @@
dependencies:
"@sinonjs/commons" "^1.7.0"
"@solana/spl-token-registry@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@solana/spl-token-registry/-/spl-token-registry-0.2.1.tgz#79b3a8c3f3a12b7956b6ecc08052438972da5ce3"
integrity sha512-JzvezgnPftowZXwAzUR7ywm16G6eWb9E7h3iUfeMmzZStBdqB/16TE8+x09yJoYtW5B6bTa80tELB7EfBzZ15w==
dependencies:
cross-fetch "^3.0.6"
"@solana/web3.js@^0.87.2":
version "0.87.2"
resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-0.87.2.tgz#92c8d344695c6113d4e0eb3339117fbc6b22d0d2"
@ -4456,6 +4463,13 @@ create-hmac@1.1.7, create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
cross-fetch@^3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c"
integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ==
dependencies:
node-fetch "2.6.1"
cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -9085,7 +9099,7 @@ node-addon-api@^2.0.0:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32"
integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==
node-fetch@^2.2.0:
node-fetch@2.6.1, node-fetch@^2.2.0:
version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==