import React, { useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { useConnection } from '../contexts/connection'; import { useWallet } from '../contexts/wallet'; import { AccountInfo, Connection, PublicKey } from '@solana/web3.js'; import { AccountLayout, MintInfo, MintLayout, u64 } from '@solana/spl-token'; import { TokenAccount } from '../models'; import { chunks } from '../utils/utils'; import { EventEmitter } from '../utils/eventEmitter'; import { useUserAccounts } from '../hooks/useUserAccounts'; import { WRAPPED_SOL_MINT, programIds, LEND_HOST_FEE_ADDRESS, } from '../utils/ids'; const AccountsContext = React.createContext(null); const pendingCalls = new Map>(); const genericCache = new Map(); const pendingMintCalls = new Map>(); const mintCache = new Map(); export interface ParsedAccountBase { pubkey: PublicKey; account: AccountInfo; info: any; // TODO: change to unkown } export type AccountParser = ( pubkey: PublicKey, data: AccountInfo, ) => ParsedAccountBase | undefined; export interface ParsedAccount extends ParsedAccountBase { info: T; } const getMintInfo = async (connection: Connection, pubKey: PublicKey) => { const info = await connection.getAccountInfo(pubKey); if (info === null) { throw new Error('Failed to find mint account'); } const data = Buffer.from(info.data); return deserializeMint(data); }; export const MintParser = (pubKey: PublicKey, info: AccountInfo) => { const buffer = Buffer.from(info.data); const data = deserializeMint(buffer); const details = { pubkey: pubKey, account: { ...info, }, info: data, } as ParsedAccountBase; return details; }; export const TokenAccountParser = ( pubKey: PublicKey, info: AccountInfo, ) => { const buffer = Buffer.from(info.data); const data = deserializeAccount(buffer); const details = { pubkey: pubKey, account: { ...info, }, info: data, } as TokenAccount; return details; }; export const GenericAccountParser = ( pubKey: PublicKey, info: AccountInfo, ) => { const buffer = Buffer.from(info.data); const details = { pubkey: pubKey, account: { ...info, }, info: buffer, } as ParsedAccountBase; return details; }; export const keyToAccountParser = new Map(); export const cache = { emitter: new EventEmitter(), query: async ( connection: Connection, pubKey: string | PublicKey, parser?: AccountParser, ) => { let id: PublicKey; if (typeof pubKey === 'string') { id = new PublicKey(pubKey); } else { id = pubKey; } const address = id.toBase58(); let account = genericCache.get(address); if (account) { return account; } let query = pendingCalls.get(address); if (query) { return query; } // TODO: refactor to use multiple accounts query with flush like behavior query = connection.getAccountInfo(id).then(data => { if (!data) { throw new Error('Account not found'); } return cache.add(id, data, parser); }) as Promise; pendingCalls.set(address, query as any); return query; }, add: ( id: PublicKey | string, obj: AccountInfo, parser?: AccountParser, ) => { if (obj.data.length === 0) { return; } const address = typeof id === 'string' ? id : id?.toBase58(); const deserialize = parser ? parser : keyToAccountParser.get(address); if (!deserialize) { throw new Error( 'Deserializer needs to be registered or passed as a parameter', ); } cache.registerParser(id, deserialize); pendingCalls.delete(address); const account = deserialize(new PublicKey(address), obj); if (!account) { return; } const isNew = !genericCache.has(address); genericCache.set(address, account); cache.emitter.raiseCacheUpdated(address, isNew, deserialize); return account; }, get: (pubKey: string | PublicKey) => { let key: string; if (typeof pubKey !== 'string') { key = pubKey.toBase58(); } else { key = pubKey; } return genericCache.get(key); }, delete: (pubKey: string | PublicKey) => { let key: string; if (typeof pubKey !== 'string') { key = pubKey.toBase58(); } else { key = pubKey; } if (genericCache.get(key)) { genericCache.delete(key); cache.emitter.raiseCacheDeleted(key); return true; } return false; }, byParser: (parser: AccountParser) => { const result: string[] = []; for (const id of keyToAccountParser.keys()) { if (keyToAccountParser.get(id) === parser) { result.push(id); } } return result; }, registerParser: (pubkey: PublicKey | string, parser: AccountParser) => { if (pubkey) { const address = typeof pubkey === 'string' ? pubkey : pubkey?.toBase58(); keyToAccountParser.set(address, parser); } return pubkey; }, queryMint: async (connection: Connection, pubKey: string | PublicKey) => { let id: PublicKey; if (typeof pubKey === 'string') { id = new PublicKey(pubKey); } else { id = pubKey; } const address = id.toBase58(); let mint = mintCache.get(address); if (mint) { return mint; } let query = pendingMintCalls.get(address); if (query) { return query; } query = getMintInfo(connection, id).then(data => { pendingMintCalls.delete(address); mintCache.set(address, data); return data; }) as Promise; pendingMintCalls.set(address, query as any); return query; }, getMint: (pubKey: string | PublicKey) => { let key: string; if (typeof pubKey !== 'string') { key = pubKey.toBase58(); } else { key = pubKey; } return mintCache.get(key); }, addMint: (pubKey: PublicKey, obj: AccountInfo) => { const mint = deserializeMint(obj.data); const id = pubKey.toBase58(); mintCache.set(id, mint); return mint; }, }; export const useAccountsContext = () => { const context = useContext(AccountsContext); return context; }; function wrapNativeAccount( pubkey: PublicKey, account?: AccountInfo, ): TokenAccount | undefined { if (!account) { return undefined; } return { pubkey: pubkey, account, info: { address: pubkey, mint: WRAPPED_SOL_MINT, owner: pubkey, amount: new u64(account.lamports), delegate: null, delegatedAmount: new u64(0), isInitialized: true, isFrozen: false, isNative: true, rentExemptReserve: null, closeAuthority: null, }, }; } export const getCachedAccount = ( predicate: (account: TokenAccount) => boolean, ) => { for (const account of genericCache.values()) { if (predicate(account)) { return account as TokenAccount; } } }; const UseNativeAccount = () => { const connection = useConnection(); const { wallet } = useWallet(); const [nativeAccount, setNativeAccount] = useState>(); const updateCache = useCallback( account => { if (wallet && wallet.publicKey) { const wrapped = wrapNativeAccount(wallet.publicKey, account); if (wrapped !== undefined && wallet) { const id = wallet.publicKey?.toBase58(); cache.registerParser(id, TokenAccountParser); genericCache.set(id, wrapped as TokenAccount); cache.emitter.raiseCacheUpdated(id, false, TokenAccountParser); } } }, [wallet], ); useEffect(() => { let subId = 0; const updateAccount = (account: AccountInfo | null) => { if (account) { updateCache(account); setNativeAccount(account); } }; (async () => { if (!connection || !wallet?.publicKey) { return; } const account = await connection.getAccountInfo(wallet.publicKey) updateAccount(account); subId = connection.onAccountChange(wallet.publicKey, updateAccount); })(); return () => { if (subId) { connection.removeAccountChangeListener(subId); } } }, [setNativeAccount, wallet, wallet?.publicKey, connection, updateCache]); return { nativeAccount }; }; const PRECACHED_OWNERS = new Set(); const precacheUserTokenAccounts = async ( connection: Connection, owner?: PublicKey, ) => { if (!owner) { return; } // used for filtering account updates over websocket PRECACHED_OWNERS.add(owner.toBase58()); // user accounts are updated via ws subscription const accounts = await connection.getTokenAccountsByOwner(owner, { programId: programIds().token, }); accounts.value.forEach(info => { cache.add(info.pubkey.toBase58(), info.account, TokenAccountParser); }); }; export function AccountsProvider({ children = null as any }) { const connection = useConnection(); const { wallet, connected } = useWallet(); const [tokenAccounts, setTokenAccounts] = useState([]); const [userAccounts, setUserAccounts] = useState([]); const { nativeAccount } = UseNativeAccount(); const selectUserAccounts = useCallback(() => { return cache .byParser(TokenAccountParser) .map(id => cache.get(id)) .filter( a => a && a.info.owner.toBase58() === wallet?.publicKey?.toBase58(), ) .map(a => a as TokenAccount); }, [wallet]); useEffect(() => { const accounts = selectUserAccounts().filter( a => a !== undefined, ) as TokenAccount[]; setUserAccounts(accounts); }, [nativeAccount, wallet, tokenAccounts, selectUserAccounts]); useEffect(() => { const subs: number[] = []; cache.emitter.onCache(args => { if (args.isNew) { let id = args.id; let deserialize = args.parser; connection.onAccountChange(new PublicKey(id), info => { cache.add(id, info, deserialize); }); } }); return () => { subs.forEach(id => connection.removeAccountChangeListener(id)); }; }, [connection]); const publicKey = wallet?.publicKey; useEffect(() => { if (!connection || !publicKey) { setTokenAccounts([]); } else { precacheUserTokenAccounts(connection, LEND_HOST_FEE_ADDRESS); precacheUserTokenAccounts(connection, publicKey).then(() => { setTokenAccounts(selectUserAccounts()); }); // This can return different types of accounts: token-account, mint, multisig // TODO: web3.js expose ability to filter. // this should use only filter syntax to only get accounts that are owned by user const tokenSubID = connection.onProgramAccountChange( programIds().token, info => { // TODO: fix type in web3.js const id = (info.accountId as unknown) as string; // TODO: do we need a better way to identify layout (maybe a enum identifing type?) if (info.accountInfo.data.length === AccountLayout.span) { const data = deserializeAccount(info.accountInfo.data); if (PRECACHED_OWNERS.has(data.owner.toBase58())) { cache.add(id, info.accountInfo, TokenAccountParser); setTokenAccounts(selectUserAccounts()); } } }, 'singleGossip', ); return () => { connection.removeProgramAccountChangeListener(tokenSubID); }; } }, [connection, connected, publicKey, selectUserAccounts]); return ( {children} ); } export function useNativeAccount() { const context = useContext(AccountsContext); return { account: context.nativeAccount as AccountInfo, }; } export const getMultipleAccounts = async ( connection: any, keys: string[], commitment: string, ) => { const result = await Promise.all( chunks(keys, 99).map(chunk => getMultipleAccountsCore(connection, chunk, commitment), ), ); const array = result .map( a => a.array.map(acc => { if (!acc) { return undefined; } const { data, ...rest } = acc; const obj = { ...rest, data: Buffer.from(data[0], 'base64'), } as AccountInfo; return obj; }) as AccountInfo[], ) .flat(); return { keys, array }; }; const getMultipleAccountsCore = async ( connection: any, keys: string[], commitment: string, ) => { const args = connection._buildArgs([keys], commitment, 'base64'); const unsafeRes = await connection._rpcRequest('getMultipleAccounts', args); if (unsafeRes.error) { throw new Error( 'failed to get info about account ' + unsafeRes.error.message, ); } if (unsafeRes.result.value) { const array = unsafeRes.result.value as AccountInfo[]; return { keys, array }; } // TODO: fix throw new Error(); }; export function useMint(key?: string | PublicKey) { const connection = useConnection(); const [mint, setMint] = useState(); const id = typeof key === 'string' ? key : key?.toBase58(); useEffect(() => { if (!id) { return; } cache .query(connection, id, MintParser) .then(acc => setMint(acc.info as any)) .catch(err => console.log(err)); const dispose = cache.emitter.onCache(e => { const event = e; if (event.id === id) { cache .query(connection, id, MintParser) .then(mint => setMint(mint.info as any)); } }); return () => { dispose(); }; }, [connection, id]); return mint; } export function useAccount(pubKey?: PublicKey) { const connection = useConnection(); const [account, setAccount] = useState(); const key = pubKey?.toBase58(); useEffect(() => { const query = async () => { try { if (!key) { return; } const acc = await cache .query(connection, key, TokenAccountParser) .catch(err => console.log(err)); if (acc) { setAccount(acc); } } catch (err) { console.error(err); } }; query(); const dispose = cache.emitter.onCache(e => { const event = e; if (event.id === key) { query(); } }); return () => { dispose(); }; }, [connection, key]); return account; } // TODO: expose in spl package export const deserializeAccount = (data: Buffer) => { const accountInfo = AccountLayout.decode(data); accountInfo.mint = new PublicKey(accountInfo.mint); accountInfo.owner = new PublicKey(accountInfo.owner); accountInfo.amount = u64.fromBuffer(accountInfo.amount); if (accountInfo.delegateOption === 0) { accountInfo.delegate = null; accountInfo.delegatedAmount = new u64(0); } else { accountInfo.delegate = new PublicKey(accountInfo.delegate); accountInfo.delegatedAmount = u64.fromBuffer(accountInfo.delegatedAmount); } accountInfo.isInitialized = accountInfo.state !== 0; accountInfo.isFrozen = accountInfo.state === 2; if (accountInfo.isNativeOption === 1) { accountInfo.rentExemptReserve = u64.fromBuffer(accountInfo.isNative); accountInfo.isNative = true; } else { accountInfo.rentExemptReserve = null; accountInfo.isNative = false; } if (accountInfo.closeAuthorityOption === 0) { accountInfo.closeAuthority = null; } else { accountInfo.closeAuthority = new PublicKey(accountInfo.closeAuthority); } return accountInfo; }; // TODO: expose in spl package export const deserializeMint = (data: Buffer) => { if (data.length !== MintLayout.span) { throw new Error('Not a valid Mint'); } const mintInfo = MintLayout.decode(data); if (mintInfo.mintAuthorityOption === 0) { mintInfo.mintAuthority = null; } else { mintInfo.mintAuthority = new PublicKey(mintInfo.mintAuthority); } mintInfo.supply = u64.fromBuffer(mintInfo.supply); mintInfo.isInitialized = mintInfo.isInitialized !== 0; if (mintInfo.freezeAuthorityOption === 0) { mintInfo.freezeAuthority = null; } else { mintInfo.freezeAuthority = new PublicKey(mintInfo.freezeAuthority); } return mintInfo as MintInfo; };