diff --git a/packages/state/query/queries/nft.ts b/packages/state/query/queries/nft.ts index 4eb2aed7da..f471f0fd68 100644 --- a/packages/state/query/queries/nft.ts +++ b/packages/state/query/queries/nft.ts @@ -1,13 +1,21 @@ import { QueryClient, queryOptions } from '@tanstack/react-query' -import { ChainId } from '@dao-dao/types' +import { ChainId, NftCardInfo, NftUriData, TokenType } from '@dao-dao/types' +import { + STARGAZE_URL_BASE, + getNftKey, + nftCardInfoFromStargazeIndexerNft, + transformIpfsUrlToHttpsIfNecessary, +} from '@dao-dao/utils' +import { stargazeIndexerClient, stargazeTokenQuery } from '../../graphql' import { cw721BaseQueries, daoVotingCw721StakedExtraQueries, daoVotingOnftStakedExtraQueries, } from './contracts' import { omniflixQueries } from './omniflix' +import { tokenQueries } from './token' /** * Fetch owner of NFT, or staked if NFT is staked with the given staking @@ -87,6 +95,255 @@ export const fetchNftOwnerOrStaker = async ( } } +/** + * Fetch NFT card info. + */ +export const fetchNftCardInfo = async ( + queryClient: QueryClient, + { + chainId, + collection, + tokenId, + }: { + chainId: string + collection: string + tokenId: string + } +): Promise => { + // Use Stargaze indexer when possible. Fallback to contract query. + if ( + chainId === ChainId.StargazeMainnet || + chainId === ChainId.StargazeTestnet + ) { + let data + try { + data = ( + await stargazeIndexerClient.query({ + query: stargazeTokenQuery, + variables: { + collectionAddr: collection, + tokenId, + }, + }) + ).data + } catch (err) { + console.error(err) + } + + if (data?.token) { + const genericToken = data.token?.highestOffer?.offerPrice?.denom + ? await queryClient.fetchQuery( + tokenQueries.info(queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: data.token.highestOffer.offerPrice.denom, + }) + ) + : undefined + + return nftCardInfoFromStargazeIndexerNft( + chainId, + data.token, + genericToken + ) + } + } + + if ( + chainId === ChainId.OmniflixHubMainnet || + chainId === ChainId.OmniflixHubTestnet + ) { + const [collectionInfo, onft] = await Promise.all([ + queryClient.fetchQuery( + omniflixQueries.onftCollectionInfo({ + chainId, + id: collection, + }) + ), + queryClient.fetchQuery( + omniflixQueries.onft({ + chainId, + collectionId: collection, + tokenId, + }) + ), + ]) + + return { + chainId, + key: getNftKey(chainId, collection, tokenId), + collectionAddress: collection, + collectionName: collectionInfo.name, + tokenId, + owner: onft.owner, + externalLink: { + href: `https://omniflix.market/c/${collection}/${tokenId}`, + name: 'OmniFlix', + }, + imageUrl: onft.metadata?.mediaUri, + name: onft.metadata?.name || tokenId, + description: onft.metadata?.description, + } + } + + const tokenInfo = await queryClient.fetchQuery( + cw721BaseQueries.nftInfo({ + chainId, + contractAddress: collection, + args: { + tokenId, + }, + }) + ) + + return await queryClient.fetchQuery( + nftQueries.cardInfoFromUri(queryClient, { + chainId, + collection, + tokenId, + tokenUri: tokenInfo.token_uri, + }) + ) +} + +/** + * Fetch NFT card info given its token URI. + */ +export const fetchNftCardInfoFromUri = async ( + queryClient: QueryClient, + { + chainId, + collection, + tokenId, + tokenUri, + }: { + chainId: string + collection: string + tokenId: string + tokenUri?: string | null | undefined + } +): Promise => { + const collectionInfo = await queryClient.fetchQuery( + cw721BaseQueries.contractInfo({ + chainId, + contractAddress: collection, + }) + ) + + const metadata = + (tokenUri && + (await queryClient + .fetchQuery(nftQueries.metadataFromUri({ tokenUri })) + .catch(() => undefined))) || + undefined + const { name = '', description, imageUrl, externalLink } = metadata || {} + + const info: NftCardInfo = { + key: getNftKey(chainId, collection, tokenId), + collectionAddress: collection, + collectionName: collectionInfo.name, + tokenId, + externalLink: + externalLink || + (chainId === ChainId.StargazeMainnet || + chainId === ChainId.StargazeTestnet + ? { + href: `${STARGAZE_URL_BASE}/media/${collection}/${tokenId}`, + name: 'Stargaze', + } + : undefined), + // Default to tokenUri; this gets overwritten if tokenUri contains valid + // metadata and has an image. + imageUrl: imageUrl || tokenUri || undefined, + metadata, + name, + description, + chainId, + } + + return info +} + +/** + * Parse NFT metadata from a token URI. + * + * Tries to parse [EIP-721] metadata out of an NFT's metadata JSON. + * + * [EIP-721]: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md + */ +export const fetchNftMetadataFromUri = async ({ + tokenUri, +}: { + tokenUri: string +}): Promise => { + // Transform IPFS url if necessary. + let response = await fetch(transformIpfsUrlToHttpsIfNecessary(tokenUri)) + + if (!response.ok) { + // Sometimes `tokenUri` is missing a `.json` extension, so try again on + // failure in that case. + if (!tokenUri.endsWith('.json')) { + response = await fetch( + transformIpfsUrlToHttpsIfNecessary(tokenUri + '.json') + ) + } + + if (!response.ok) { + throw new Error( + `Failed to fetch NFT metadata: [${response.status} ${ + response.statusText + }] ${await response.text().catch(() => '')}`.trim() + ) + } + } + + const data = await response.json() + + let name + let description + let imageUrl + let externalLink + + if (typeof data.name === 'string' && !!data.name.trim()) { + name = data.name + } + + if (typeof data.description === 'string' && !!data.description.trim()) { + description = data.description + } + + if (typeof data.image === 'string' && !!data.image) { + imageUrl = transformIpfsUrlToHttpsIfNecessary(data.image) + } + + if (typeof data.external_url === 'string' && !!data.external_url.trim()) { + const externalUrl = transformIpfsUrlToHttpsIfNecessary(data.external_url) + const externalUrlDomain = new URL(externalUrl).hostname + externalLink = { + href: externalUrl, + name: HostnameMap[externalUrlDomain] ?? externalUrlDomain, + } + } + + return { + // Include all metadata. + ...data, + + // Override specifics. + name, + description, + imageUrl, + externalLink, + } +} + +// Maps domain -> human readable name. If a domain is in this set, NFTs +// associated with it will have their external links displayed using the human +// readable name provided here. +const HostnameMap: Record = { + 'stargaze.zone': 'Stargaze', +} + export const nftQueries = { /** * Fetch owner of NFT, or staked if NFT is staked with the given staking @@ -100,4 +357,34 @@ export const nftQueries = { queryKey: ['nft', 'ownerOrStaker', options], queryFn: () => fetchNftOwnerOrStaker(queryClient, options), }), + /** + * Fetch NFT card info. + */ + cardInfo: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['nft', 'cardInfo', options], + queryFn: () => fetchNftCardInfo(queryClient, options), + }), + /** + * Fetch NFT card info given its token URI. + */ + cardInfoFromUri: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['nft', 'cardInfoFromUri', options], + queryFn: () => fetchNftCardInfoFromUri(queryClient, options), + }), + /** + * Fetch NFT metadata from a token URI. + */ + metadataFromUri: (options: Parameters[0]) => + queryOptions({ + queryKey: ['nft', 'metadataFromUri', options], + queryFn: () => fetchNftMetadataFromUri(options), + }), } diff --git a/packages/state/recoil/selectors/nft.ts b/packages/state/recoil/selectors/nft.ts index 7b23cde616..c9d3591d17 100644 --- a/packages/state/recoil/selectors/nft.ts +++ b/packages/state/recoil/selectors/nft.ts @@ -7,22 +7,18 @@ import { LoadingDataWithError, LoadingNfts, NftCardInfo, - NftUriData, TokenType, WithChainId, } from '@dao-dao/types' import { MAINNET, - STARGAZE_URL_BASE, combineLoadingDataWithErrors, getNftKey, nftCardInfoFromStargazeIndexerNft, - transformIpfsUrlToHttpsIfNecessary, } from '@dao-dao/utils' import { stargazeIndexerClient, - stargazeTokenQuery, stargazeTokensForOwnerQuery, } from '../../graphql' import { omniflixQueries } from '../../query' @@ -37,79 +33,6 @@ import { queryAccountIndexerSelector } from './indexer' import { stargazeWalletUsdValueSelector } from './stargaze' import { genericTokenSelector } from './token' -// Tries to parse [EIP-721] metadata out of an NFT's metadata JSON. -// -// [EIP-721]: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md -export const nftUriDataSelector = selectorFamily< - NftUriData | undefined, - string ->({ - key: 'nftUriData', - get: (tokenUri) => async () => { - try { - // Transform IPFS url if necessary. - let response = await fetch(transformIpfsUrlToHttpsIfNecessary(tokenUri)) - - if (!response.ok) { - // Sometimes `tokenUri` is missing a `.json` extension, so try again on - // failure in that case. - if (!tokenUri.endsWith('.json')) { - response = await fetch( - transformIpfsUrlToHttpsIfNecessary(tokenUri + '.json') - ) - } - - if (!response.ok) { - return - } - } - - const data = await response.json() - - let name - let description - let imageUrl - let externalLink - - if (typeof data.name === 'string' && !!data.name.trim()) { - name = data.name - } - - if (typeof data.description === 'string' && !!data.description.trim()) { - description = data.description - } - - if (typeof data.image === 'string' && !!data.image) { - imageUrl = transformIpfsUrlToHttpsIfNecessary(data.image) - } - - if (typeof data.external_url === 'string' && !!data.external_url.trim()) { - const externalUrl = transformIpfsUrlToHttpsIfNecessary( - data.external_url - ) - const externalUrlDomain = new URL(externalUrl).hostname - externalLink = { - href: externalUrl, - name: HostnameMap[externalUrlDomain] ?? externalUrlDomain, - } - } - - return { - // Include all metadata. - ...data, - - // Override specifics. - name, - description, - imageUrl, - externalLink, - } - } catch (err) { - console.error(err) - } - }, -}) - export const allNftUsdValueSelector = selectorFamily< number, WithChainId<{ address: string }> @@ -139,13 +62,6 @@ export const allNftUsdValueSelector = selectorFamily< }, }) -// Maps domain -> human readable name. If a domain is in this set, NFTs -// associated with it will have their external links displayed using the human -// readable name provided here. -const HostnameMap: Record = { - 'stargaze.zone': 'Stargaze', -} - const STARGAZE_INDEXER_TOKENS_LIMIT = 100 export const walletStargazeNftCardInfosSelector = selectorFamily< NftCardInfo[], @@ -247,163 +163,6 @@ export const walletStargazeNftCardInfosSelector = selectorFamily< }, }) -export const nftCardInfoWithUriSelector = selectorFamily< - NftCardInfo, - WithChainId<{ - collection: string - tokenId: string - tokenUri?: string | null | undefined - }> ->({ - key: 'nftCardInfo', - get: - ({ tokenId, collection, tokenUri, chainId }) => - async ({ get }) => { - const collectionInfo = get( - CommonNftSelectors.contractInfoSelector({ - contractAddress: collection, - chainId, - params: [], - }) - ) - - const metadata = - (tokenUri && get(nftUriDataSelector(tokenUri))) || undefined - const { name = '', description, imageUrl, externalLink } = metadata || {} - - const info: NftCardInfo = { - key: getNftKey(chainId, collection, tokenId), - collectionAddress: collection, - collectionName: collectionInfo.name, - tokenId, - externalLink: - externalLink || - (chainId === ChainId.StargazeMainnet || - chainId === ChainId.StargazeTestnet - ? { - href: `${STARGAZE_URL_BASE}/media/${collection}/${tokenId}`, - name: 'Stargaze', - } - : undefined), - // Default to tokenUri; this gets overwritten if tokenUri contains valid - // metadata and has an image. - imageUrl: imageUrl || tokenUri || undefined, - metadata, - name, - description, - chainId, - } - - return info - }, -}) - -// TODO(omniflix): move this to react-query and load ONFT JSON metadata URI -export const nftCardInfoSelector = selectorFamily< - NftCardInfo, - WithChainId<{ tokenId: string; collection: string }> ->({ - key: 'nftCardInfo', - get: - ({ tokenId, collection, chainId }) => - async ({ get }) => { - // Use Stargaze indexer when possible. Fallback to contract query. - if ( - chainId === ChainId.StargazeMainnet || - chainId === ChainId.StargazeTestnet - ) { - let data - try { - data = ( - await stargazeIndexerClient.query({ - query: stargazeTokenQuery, - variables: { - collectionAddr: collection, - tokenId, - }, - }) - ).data - } catch (err) { - console.error(err) - } - - if (data?.token) { - const genericToken = data.token?.highestOffer?.offerPrice?.denom - ? get( - genericTokenSelector({ - chainId, - type: TokenType.Native, - denomOrAddress: data.token.highestOffer.offerPrice.denom, - }) - ) - : undefined - - return nftCardInfoFromStargazeIndexerNft( - chainId, - data.token, - genericToken - ) - } - } - - if ( - chainId === ChainId.OmniflixHubMainnet || - chainId === ChainId.OmniflixHubTestnet - ) { - const queryClient = get(queryClientAtom) - - const [collectionInfo, onft] = await Promise.all([ - queryClient.fetchQuery( - omniflixQueries.onftCollectionInfo({ - chainId, - id: collection, - }) - ), - queryClient.fetchQuery( - omniflixQueries.onft({ - chainId, - collectionId: collection, - tokenId, - }) - ), - ]) - - return { - chainId, - key: getNftKey(chainId, collection, tokenId), - collectionAddress: collection, - collectionName: collectionInfo.name, - tokenId, - owner: onft.owner, - externalLink: { - href: `https://omniflix.market/c/${collection}/${tokenId}`, - name: 'OmniFlix', - }, - imageUrl: onft.metadata?.mediaUri, - name: onft.metadata?.name || tokenId, - description: onft.metadata?.description, - } - } - - const tokenInfo = get( - CommonNftSelectors.nftInfoSelector({ - contractAddress: collection, - chainId, - params: [{ tokenId }], - }) - ) - - return get( - nftCardInfoWithUriSelector({ - tokenId, - collection, - tokenUri: tokenInfo.token_uri, - chainId, - }) - ) - }, -}) - export const lazyNftCardInfosForDaoSelector = selectorFamily< // Map chain ID to DAO-owned NFTs on that chain. LoadingNfts, diff --git a/packages/stateful/actions/core/actions/BurnNft/Component.tsx b/packages/stateful/actions/core/actions/BurnNft/Component.tsx index d6df4441a6..6241f1900f 100644 --- a/packages/stateful/actions/core/actions/BurnNft/Component.tsx +++ b/packages/stateful/actions/core/actions/BurnNft/Component.tsx @@ -28,7 +28,8 @@ export interface BurnNftOptions { // The set of NFTs that may be burned as part of this action. options: LoadingDataWithError // Information about the NFT currently selected. If errored, it may be burnt. - nftInfo: LoadingDataWithError + // If undefined, no NFT is selected. + nftInfo: LoadingDataWithError | undefined NftSelectionModal: ComponentType } @@ -85,23 +86,26 @@ export const BurnNft: ActionComponent = ({ return ( <>
- {nftInfo.loading ? ( - - ) : !nftInfo.errored && nftInfo.data ? ( - - ) : ( - // If errored loading NFT and not creating, token likely burned. - nftInfo.errored && - !isCreating && ( -

{t('info.tokenBurned', { tokenId })}

- ) - )} + {nftInfo && + (nftInfo.loading ? ( + + ) : !nftInfo.errored ? ( + + ) : ( + // If errored loading NFT and not creating, token likely burned. + nftInfo.errored && + !isCreating && ( +

+ {t('info.tokenBurned', { tokenId })} +

+ ) + ))} {isCreating && (
- {nftInfo.loading ? ( - - ) : nftInfo.errored ? ( - - ) : ( - nftInfo.data && - )} + {nftInfo && + (nftInfo.loading ? ( + + ) : nftInfo.errored ? ( + + ) : ( + + ))} {isCreating && (