From 65b885c9dd0330852a1e8fef397ee961a4e1673d Mon Sep 17 00:00:00 2001 From: exromany Date: Mon, 16 Aug 2021 12:58:34 +0300 Subject: [PATCH] split contexts/accounts --- js/packages/common/src/actions/account.ts | 3 +- js/packages/common/src/actions/auction.ts | 2 +- js/packages/common/src/contexts/accounts.tsx | 664 ------------------ .../common/src/contexts/accounts/accounts.tsx | 286 ++++++++ .../common/src/contexts/accounts/cache.ts | 193 +++++ .../src/contexts/accounts/deserialize.ts | 63 ++ .../contexts/accounts/getMultipleAccounts.ts | 56 ++ .../common/src/contexts/accounts/index.ts | 6 + .../common/src/contexts/accounts/parsesrs.ts | 64 ++ .../common/src/contexts/accounts/types.ts | 17 + .../contexts/meta/isMetadataPartOfStore.ts | 2 +- .../common/src/contexts/meta/loadAccounts.ts | 3 +- .../src/contexts/meta/processAuctions.ts | 3 +- .../src/contexts/meta/processMetaData.ts | 2 +- .../contexts/meta/processMetaplexAccounts.ts | 3 +- .../src/contexts/meta/processVaultData.ts | 2 +- .../contexts/meta/queryExtendedMetadata.ts | 10 +- js/packages/common/src/contexts/meta/types.ts | 2 +- js/packages/common/src/utils/ids.ts | 4 + js/packages/web/src/contexts/index.tsx | 2 +- js/packages/web/src/views/admin/index.tsx | 5 +- js/packages/web/src/views/analytics/index.tsx | 6 +- 22 files changed, 716 insertions(+), 682 deletions(-) delete mode 100644 js/packages/common/src/contexts/accounts.tsx create mode 100644 js/packages/common/src/contexts/accounts/accounts.tsx create mode 100644 js/packages/common/src/contexts/accounts/cache.ts create mode 100644 js/packages/common/src/contexts/accounts/deserialize.ts create mode 100644 js/packages/common/src/contexts/accounts/getMultipleAccounts.ts create mode 100644 js/packages/common/src/contexts/accounts/index.ts create mode 100644 js/packages/common/src/contexts/accounts/parsesrs.ts create mode 100644 js/packages/common/src/contexts/accounts/types.ts diff --git a/js/packages/common/src/actions/account.ts b/js/packages/common/src/actions/account.ts index 4ce7069..133222d 100644 --- a/js/packages/common/src/actions/account.ts +++ b/js/packages/common/src/actions/account.ts @@ -13,7 +13,8 @@ import { } from '../utils/ids'; import { programIds } from '../utils/programIds'; import { TokenAccount } from '../models/account'; -import { cache, TokenAccountParser } from '../contexts/accounts'; +import { cache } from '../contexts/accounts/cache'; +import { TokenAccountParser } from '../contexts/accounts/parsesrs'; export function ensureSplAccount( instructions: TransactionInstruction[], diff --git a/js/packages/common/src/actions/auction.ts b/js/packages/common/src/actions/auction.ts index abab2ed..793529a 100644 --- a/js/packages/common/src/actions/auction.ts +++ b/js/packages/common/src/actions/auction.ts @@ -8,7 +8,7 @@ import { import { programIds } from '../utils/programIds'; import { deserializeUnchecked, serialize } from 'borsh'; import BN from 'bn.js'; -import { AccountParser } from '../contexts'; +import { AccountParser } from '../contexts/accounts/types'; import moment from 'moment'; import { findProgramAddress, StringPublicKey, toPublicKey } from '../utils'; export const AUCTION_PREFIX = 'auction'; diff --git a/js/packages/common/src/contexts/accounts.tsx b/js/packages/common/src/contexts/accounts.tsx deleted file mode 100644 index b6b08a7..0000000 --- a/js/packages/common/src/contexts/accounts.tsx +++ /dev/null @@ -1,664 +0,0 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; -import { useConnection } from '../contexts/connection'; -import { useWallet } from '@solana/wallet-adapter-react'; -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 { StringPublicKey, WRAPPED_SOL_MINT } from '../utils/ids'; -import { programIds } from '../utils/programIds'; - -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: StringPublicKey; - account: AccountInfo; - info: any; // TODO: change to unknown -} - -export type AccountParser = ( - pubkey: StringPublicKey, - 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: string, 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: string, - info: AccountInfo, -) => { - // Sometimes a wrapped sol account gets closed, goes to 0 length, - // triggers an update over wss which triggers this guy to get called - // since your UI already logged that pubkey as a token account. Check for length. - if (info.data.length > 0) { - 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: string, - 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, - isActive?: boolean | undefined | ((parsed: any) => boolean), - ) => { - 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(address, obj); - if (!account) { - return; - } - - if (isActive === undefined) isActive = true; - else if (isActive instanceof Function) isActive = isActive(account); - - const isNew = !genericCache.has(address); - - genericCache.set(address, account); - cache.emitter.raiseCacheUpdated(address, isNew, deserialize, isActive); - 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: string, - account?: AccountInfo, -): TokenAccount | undefined { - if (!account) { - return undefined; - } - - const key = new PublicKey(pubkey); - - return { - pubkey: pubkey, - account, - info: { - address: key, - mint: WRAPPED_SOL_MINT, - owner: key, - 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 { publicKey } = useWallet(); - - const [nativeAccount, setNativeAccount] = useState>(); - - const updateCache = useCallback( - account => { - if (publicKey) { - const wrapped = wrapNativeAccount(publicKey.toBase58(), account); - if (wrapped !== undefined) { - const id = publicKey.toBase58(); - cache.registerParser(id, TokenAccountParser); - genericCache.set(id, wrapped as TokenAccount); - cache.emitter.raiseCacheUpdated(id, false, TokenAccountParser, true); - } - } - }, - [publicKey], - ); - - useEffect(() => { - let subId = 0; - const updateAccount = (account: AccountInfo | null) => { - if (account) { - updateCache(account); - setNativeAccount(account); - } - }; - - (async () => { - if (!connection || !publicKey) { - return; - } - - const account = await connection.getAccountInfo(publicKey); - updateAccount(account); - - subId = connection.onAccountChange(publicKey, updateAccount); - })(); - - return () => { - if (subId) { - connection.removeAccountChangeListener(subId); - } - }; - }, [setNativeAccount, 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 { publicKey } = useWallet(); - const [tokenAccounts, setTokenAccounts] = useState([]); - const [userAccounts, setUserAccounts] = useState([]); - const { nativeAccount } = UseNativeAccount(); - const walletKey = publicKey?.toBase58(); - - const selectUserAccounts = useCallback(() => { - return cache - .byParser(TokenAccountParser) - .map(id => cache.get(id)) - .filter(a => a && a.info.owner.toBase58() === walletKey) - .map(a => a as TokenAccount); - }, [walletKey]); - - useEffect(() => { - const accounts = selectUserAccounts().filter( - a => a !== undefined, - ) as TokenAccount[]; - setUserAccounts(accounts); - }, [nativeAccount, tokenAccounts, selectUserAccounts]); - - useEffect(() => { - const subs: number[] = []; - cache.emitter.onCache(args => { - if (args.isNew && args.isActive) { - 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]); - - useEffect(() => { - if (!connection || !publicKey) { - setTokenAccounts([]); - } else { - 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, 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; -}; diff --git a/js/packages/common/src/contexts/accounts/accounts.tsx b/js/packages/common/src/contexts/accounts/accounts.tsx new file mode 100644 index 0000000..656f85d --- /dev/null +++ b/js/packages/common/src/contexts/accounts/accounts.tsx @@ -0,0 +1,286 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useWallet } from '@solana/wallet-adapter-react'; +import { AccountInfo, Connection, PublicKey } from '@solana/web3.js'; +import { AccountLayout, MintInfo, u64 } from '@solana/spl-token'; +import { useConnection } from '../../contexts/connection'; +import { TokenAccount } from '../../models'; +import { StringPublicKey, WRAPPED_SOL_MINT } from '../../utils/ids'; +import { programIds } from '../../utils/programIds'; +import { genericCache, cache } from './cache'; +import { deserializeAccount } from './deserialize'; +import { TokenAccountParser, MintParser } from './parsesrs'; + +const AccountsContext = React.createContext(null); + +export const useAccountsContext = () => { + const context = useContext(AccountsContext); + + return context; +}; + +function wrapNativeAccount( + pubkey: StringPublicKey, + account?: AccountInfo, +): TokenAccount | undefined { + if (!account) { + return undefined; + } + + const key = new PublicKey(pubkey); + + return { + pubkey: pubkey, + account, + info: { + address: key, + mint: WRAPPED_SOL_MINT, + owner: key, + amount: new u64(account.lamports), + delegate: null, + delegatedAmount: new u64(0), + isInitialized: true, + isFrozen: false, + isNative: true, + rentExemptReserve: null, + closeAuthority: null, + }, + }; +} + +const UseNativeAccount = () => { + const connection = useConnection(); + const { publicKey } = useWallet(); + + const [nativeAccount, setNativeAccount] = useState>(); + + const updateCache = useCallback( + account => { + if (publicKey) { + const wrapped = wrapNativeAccount(publicKey.toBase58(), account); + if (wrapped !== undefined) { + const id = publicKey.toBase58(); + cache.registerParser(id, TokenAccountParser); + genericCache.set(id, wrapped as TokenAccount); + cache.emitter.raiseCacheUpdated(id, false, TokenAccountParser, true); + } + } + }, + [publicKey], + ); + + useEffect(() => { + let subId = 0; + const updateAccount = (account: AccountInfo | null) => { + if (account) { + updateCache(account); + setNativeAccount(account); + } + }; + + (async () => { + if (!connection || !publicKey) { + return; + } + + const account = await connection.getAccountInfo(publicKey); + updateAccount(account); + + subId = connection.onAccountChange(publicKey, updateAccount); + })(); + + return () => { + if (subId) { + connection.removeAccountChangeListener(subId); + } + }; + }, [setNativeAccount, 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 { publicKey } = useWallet(); + const [tokenAccounts, setTokenAccounts] = useState([]); + const [userAccounts, setUserAccounts] = useState([]); + const { nativeAccount } = UseNativeAccount(); + const walletKey = publicKey?.toBase58(); + + const selectUserAccounts = useCallback(() => { + return cache + .byParser(TokenAccountParser) + .map(id => cache.get(id)) + .filter(a => a && a.info.owner.toBase58() === walletKey) + .map(a => a as TokenAccount); + }, [walletKey]); + + useEffect(() => { + const accounts = selectUserAccounts().filter( + a => a !== undefined, + ) as TokenAccount[]; + setUserAccounts(accounts); + }, [nativeAccount, tokenAccounts, selectUserAccounts]); + + useEffect(() => { + const subs: number[] = []; + cache.emitter.onCache(args => { + if (args.isNew && args.isActive) { + 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]); + + useEffect(() => { + if (!connection || !publicKey) { + setTokenAccounts([]); + } else { + 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, publicKey, selectUserAccounts]); + + return ( + + {children} + + ); +} + +export function useNativeAccount() { + const context = useContext(AccountsContext); + return { + account: context.nativeAccount as AccountInfo, + }; +} + +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; +} diff --git a/js/packages/common/src/contexts/accounts/cache.ts b/js/packages/common/src/contexts/accounts/cache.ts new file mode 100644 index 0000000..5e88ffe --- /dev/null +++ b/js/packages/common/src/contexts/accounts/cache.ts @@ -0,0 +1,193 @@ +import { AccountInfo, Connection, PublicKey } from '@solana/web3.js'; +import { MintInfo } from '@solana/spl-token'; +import { TokenAccount } from '../../models'; +import { EventEmitter } from '../../utils/eventEmitter'; +import { ParsedAccountBase, AccountParser } from './types'; +import { deserializeMint } from './deserialize'; + +export const genericCache = new Map(); +const mintCache = new Map(); +const pendingCalls = new Map>(); +const pendingMintCalls = new Map>(); + +const keyToAccountParser = new Map(); + +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 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(); + + const 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, + isActive?: boolean | undefined | ((parsed: any) => boolean), + ) => { + 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(address, obj); + if (!account) { + return; + } + + if (isActive === undefined) isActive = true; + else if (isActive instanceof Function) isActive = isActive(account); + + const isNew = !genericCache.has(address); + + genericCache.set(address, account); + cache.emitter.raiseCacheUpdated(address, isNew, deserialize, isActive); + 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(); + const 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 getCachedAccount = ( + predicate: (account: TokenAccount) => boolean, +) => { + for (const account of genericCache.values()) { + if (predicate(account)) { + return account as TokenAccount; + } + } +}; diff --git a/js/packages/common/src/contexts/accounts/deserialize.ts b/js/packages/common/src/contexts/accounts/deserialize.ts new file mode 100644 index 0000000..a5ef201 --- /dev/null +++ b/js/packages/common/src/contexts/accounts/deserialize.ts @@ -0,0 +1,63 @@ +import { PublicKey } from '@solana/web3.js'; +import { AccountLayout, MintInfo, MintLayout, u64 } from '@solana/spl-token'; + +// 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; +}; diff --git a/js/packages/common/src/contexts/accounts/getMultipleAccounts.ts b/js/packages/common/src/contexts/accounts/getMultipleAccounts.ts new file mode 100644 index 0000000..d0611c4 --- /dev/null +++ b/js/packages/common/src/contexts/accounts/getMultipleAccounts.ts @@ -0,0 +1,56 @@ +import { AccountInfo } from '@solana/web3.js'; +import { chunks } from '../../utils/utils'; + +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(); +}; diff --git a/js/packages/common/src/contexts/accounts/index.ts b/js/packages/common/src/contexts/accounts/index.ts new file mode 100644 index 0000000..8c602f0 --- /dev/null +++ b/js/packages/common/src/contexts/accounts/index.ts @@ -0,0 +1,6 @@ +export * from './accounts'; +export * from './cache'; +export * from './getMultipleAccounts'; +export * from './parsesrs'; +export * from './deserialize'; +export * from './types'; diff --git a/js/packages/common/src/contexts/accounts/parsesrs.ts b/js/packages/common/src/contexts/accounts/parsesrs.ts new file mode 100644 index 0000000..174ab9b --- /dev/null +++ b/js/packages/common/src/contexts/accounts/parsesrs.ts @@ -0,0 +1,64 @@ +import { AccountInfo } from '@solana/web3.js'; +import { TokenAccount } from '../../models'; +import { ParsedAccountBase } from './types'; +import { deserializeMint, deserializeAccount } from './deserialize'; +import { StringPublicKey } from '../../utils'; + +export const MintParser = ( + pubKey: StringPublicKey, + 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: StringPublicKey, + info: AccountInfo, +) => { + // Sometimes a wrapped sol account gets closed, goes to 0 length, + // triggers an update over wss which triggers this guy to get called + // since your UI already logged that pubkey as a token account. Check for length. + if (info.data.length > 0) { + 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: StringPublicKey, + info: AccountInfo, +) => { + const buffer = Buffer.from(info.data); + + const details = { + pubkey: pubKey, + account: { + ...info, + }, + info: buffer, + } as ParsedAccountBase; + + return details; +}; diff --git a/js/packages/common/src/contexts/accounts/types.ts b/js/packages/common/src/contexts/accounts/types.ts new file mode 100644 index 0000000..a44a3ca --- /dev/null +++ b/js/packages/common/src/contexts/accounts/types.ts @@ -0,0 +1,17 @@ +import { AccountInfo } from '@solana/web3.js'; +import { StringPublicKey } from '../../utils'; + +export interface ParsedAccountBase { + pubkey: StringPublicKey; + account: AccountInfo; + info: any; // TODO: change to unknown +} + +export type AccountParser = ( + pubkey: StringPublicKey, + data: AccountInfo, +) => ParsedAccountBase | undefined; + +export interface ParsedAccount extends ParsedAccountBase { + info: T; +} diff --git a/js/packages/common/src/contexts/meta/isMetadataPartOfStore.ts b/js/packages/common/src/contexts/meta/isMetadataPartOfStore.ts index 310a59a..b296e8a 100644 --- a/js/packages/common/src/contexts/meta/isMetadataPartOfStore.ts +++ b/js/packages/common/src/contexts/meta/isMetadataPartOfStore.ts @@ -1,6 +1,6 @@ import { Metadata } from '../../actions'; import { Store, WhitelistedCreator } from '../../models/metaplex'; -import { ParsedAccount } from '../accounts'; +import { ParsedAccount } from '../accounts/types'; export const isMetadataPartOfStore = ( m: ParsedAccount, diff --git a/js/packages/common/src/contexts/meta/loadAccounts.ts b/js/packages/common/src/contexts/meta/loadAccounts.ts index 0d37c84..d0ca5dc 100644 --- a/js/packages/common/src/contexts/meta/loadAccounts.ts +++ b/js/packages/common/src/contexts/meta/loadAccounts.ts @@ -24,7 +24,8 @@ import { processAuctions } from './processAuctions'; import { processMetaplexAccounts } from './processMetaplexAccounts'; import { processMetaData } from './processMetaData'; import { processVaultData } from './processVaultData'; -import { ParsedAccount, getMultipleAccounts } from '../accounts'; +import { ParsedAccount } from '../accounts/types'; +import { getMultipleAccounts } from '../accounts'; async function getProgramAccounts( connection: Connection, diff --git a/js/packages/common/src/contexts/meta/processAuctions.ts b/js/packages/common/src/contexts/meta/processAuctions.ts index 701eb82..b1f4105 100644 --- a/js/packages/common/src/contexts/meta/processAuctions.ts +++ b/js/packages/common/src/contexts/meta/processAuctions.ts @@ -12,7 +12,8 @@ import { MAX_AUCTION_DATA_EXTENDED_SIZE, } from '../../actions'; import { AUCTION_ID } from '../../utils'; -import { cache, ParsedAccount } from '../accounts'; +import { ParsedAccount } from '../accounts/types'; +import { cache } from '../accounts/cache'; import { CheckAccountFunc, ProcessAccountsFunc } from './types'; export const processAuctions: ProcessAccountsFunc = ( diff --git a/js/packages/common/src/contexts/meta/processMetaData.ts b/js/packages/common/src/contexts/meta/processMetaData.ts index 7570000..03020bc 100644 --- a/js/packages/common/src/contexts/meta/processMetaData.ts +++ b/js/packages/common/src/contexts/meta/processMetaData.ts @@ -11,7 +11,7 @@ import { Metadata, MetadataKey, } from '../../actions'; -import { ParsedAccount } from '../accounts'; +import { ParsedAccount } from '../accounts/types'; import { METADATA_PROGRAM_ID } from '../../utils'; export const processMetaData: ProcessAccountsFunc = ( diff --git a/js/packages/common/src/contexts/meta/processMetaplexAccounts.ts b/js/packages/common/src/contexts/meta/processMetaplexAccounts.ts index 3eabb70..ba62317 100644 --- a/js/packages/common/src/contexts/meta/processMetaplexAccounts.ts +++ b/js/packages/common/src/contexts/meta/processMetaplexAccounts.ts @@ -22,7 +22,8 @@ import { } from '../../models/metaplex'; import { ProcessAccountsFunc } from './types'; import { METAPLEX_ID, programIds } from '../../utils'; -import { cache, ParsedAccount } from '../accounts'; +import { ParsedAccount } from '../accounts/types'; +import { cache } from '../accounts/cache'; export const processMetaplexAccounts: ProcessAccountsFunc = async ( { account, pubkey }, diff --git a/js/packages/common/src/contexts/meta/processVaultData.ts b/js/packages/common/src/contexts/meta/processVaultData.ts index af51412..1ab148b 100644 --- a/js/packages/common/src/contexts/meta/processVaultData.ts +++ b/js/packages/common/src/contexts/meta/processVaultData.ts @@ -7,7 +7,7 @@ import { VaultKey, } from '../../actions'; import { VAULT_ID } from '../../utils'; -import { ParsedAccount } from '../accounts'; +import { ParsedAccount } from '../accounts/types'; import { ProcessAccountsFunc } from './types'; export const processVaultData: ProcessAccountsFunc = ( diff --git a/js/packages/common/src/contexts/meta/queryExtendedMetadata.ts b/js/packages/common/src/contexts/meta/queryExtendedMetadata.ts index 1f763b6..f265168 100644 --- a/js/packages/common/src/contexts/meta/queryExtendedMetadata.ts +++ b/js/packages/common/src/contexts/meta/queryExtendedMetadata.ts @@ -1,12 +1,10 @@ import { MintInfo } from '@solana/spl-token'; import { Connection } from '@solana/web3.js'; import { Metadata } from '../../actions'; -import { - cache, - getMultipleAccounts, - MintParser, - ParsedAccount, -} from '../accounts'; +import { ParsedAccount } from '../accounts/types'; +import { cache } from '../accounts/cache'; +import { getMultipleAccounts } from '../accounts/getMultipleAccounts'; +import { MintParser } from '../accounts/parsesrs'; export const queryExtendedMetadata = async ( connection: Connection, diff --git a/js/packages/common/src/contexts/meta/types.ts b/js/packages/common/src/contexts/meta/types.ts index b4d3109..950af1e 100644 --- a/js/packages/common/src/contexts/meta/types.ts +++ b/js/packages/common/src/contexts/meta/types.ts @@ -22,8 +22,8 @@ import { Store, WhitelistedCreator, } from '../../models/metaplex'; -import { ParsedAccount } from '../accounts'; import { PublicKeyStringAndAccount } from '../../utils'; +import { ParsedAccount } from '../accounts/types'; export interface MetaState { metadata: ParsedAccount[]; diff --git a/js/packages/common/src/utils/ids.ts b/js/packages/common/src/utils/ids.ts index 6816fff..6f53b00 100644 --- a/js/packages/common/src/utils/ids.ts +++ b/js/packages/common/src/utils/ids.ts @@ -36,6 +36,10 @@ export const toPublicKey = (key: string | PublicKey) => { return result; }; +export const pubkeyToString = (key: PublicKey | null | string = '') => { + return typeof key === 'string' ? key : key?.toBase58() || ''; +}; + export interface PublicKeyStringAndAccount { pubkey: string; account: AccountInfo; diff --git a/js/packages/web/src/contexts/index.tsx b/js/packages/web/src/contexts/index.tsx index ea3d305..0241456 100644 --- a/js/packages/web/src/contexts/index.tsx +++ b/js/packages/web/src/contexts/index.tsx @@ -1,2 +1,2 @@ -export * from '@oyster/common/dist/lib/contexts/meta/meta' +export * from '@oyster/common/dist/lib/contexts/meta/meta'; export * from './coingecko'; diff --git a/js/packages/web/src/views/admin/index.tsx b/js/packages/web/src/views/admin/index.tsx index bac0be1..f619d5f 100644 --- a/js/packages/web/src/views/admin/index.tsx +++ b/js/packages/web/src/views/admin/index.tsx @@ -12,7 +12,10 @@ import { Divider, } from 'antd'; import { useMeta } from '../../contexts'; -import { Store, WhitelistedCreator } from '@oyster/common/dist/lib/models/metaplex/index'; +import { + Store, + WhitelistedCreator, +} from '@oyster/common/dist/lib/models/metaplex/index'; import { MasterEditionV1, notify, diff --git a/js/packages/web/src/views/analytics/index.tsx b/js/packages/web/src/views/analytics/index.tsx index c7f008e..90429c5 100644 --- a/js/packages/web/src/views/analytics/index.tsx +++ b/js/packages/web/src/views/analytics/index.tsx @@ -1,7 +1,11 @@ import React, { Dispatch, SetStateAction, useState } from 'react'; import { Layout, Button, Col, Spin } from 'antd'; import { useMeta } from '../../contexts'; -import { AuctionManagerV1, AuctionManagerV2, WinningConfigType } from '@oyster/common/dist/lib/models/metaplex/index'; +import { + AuctionManagerV1, + AuctionManagerV2, + WinningConfigType, +} from '@oyster/common/dist/lib/models/metaplex/index'; import { Pie, Bar } from 'react-chartjs-2'; import { AuctionDataExtended,