diff --git a/app/address/[address]/attributes/page-client.tsx b/app/address/[address]/attributes/page-client.tsx index 3e4ecbc3..4c3402ba 100644 --- a/app/address/[address]/attributes/page-client.tsx +++ b/app/address/[address]/attributes/page-client.tsx @@ -2,8 +2,9 @@ import { MetaplexNFTAttributesCard } from '@components/account/MetaplexNFTAttributesCard'; import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer'; -import { isTokenProgramData } from '@providers/accounts'; -import React from 'react'; +import React, { Suspense } from 'react'; + +import { LoadingCard } from '@/app/components/common/LoadingCard'; type Props = Readonly<{ params: { @@ -15,11 +16,11 @@ function MetaplexNFTAttributesCardRenderer({ account, onNotFound, }: React.ComponentProps['renderComponent']>) { - const parsedData = account?.data?.parsed; - if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) { - return onNotFound(); - } - return ; + return ( + }> + {} + + ); } export default function MetaplexNFTAttributesPageClient({ params: { address } }: Props) { diff --git a/app/address/[address]/compression/page-client.tsx b/app/address/[address]/compression/page-client.tsx new file mode 100644 index 00000000..e544024e --- /dev/null +++ b/app/address/[address]/compression/page-client.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer'; +import React, { Suspense } from 'react'; + +import { CompressedNFTInfoCard } from '@/app/components/account/CompressedNFTInfoCard'; +import { LoadingCard } from '@/app/components/common/LoadingCard'; + +type Props = Readonly<{ + params: { + address: string; + }; +}>; + +function CompressionCardRenderer({ + account, + onNotFound, +}: React.ComponentProps['renderComponent']>) { + return ( + }> + {} + + ); +} + +export default function CompressionPageClient({ params: { address } }: Props) { + return ; +} diff --git a/app/address/[address]/compression/page.tsx b/app/address/[address]/compression/page.tsx new file mode 100644 index 00000000..af34b529 --- /dev/null +++ b/app/address/[address]/compression/page.tsx @@ -0,0 +1,21 @@ +import getReadableTitleFromAddress, { AddressPageMetadataProps } from '@utils/get-readable-title-from-address'; +import { Metadata } from 'next/types'; + +import CompressionPageClient from './page-client'; + +type Props = Readonly<{ + params: { + address: string; + }; +}>; + +export async function generateMetadata(props: AddressPageMetadataProps): Promise { + return { + description: `Information about the Compressed NFT with address ${props.params.address} on Solana`, + title: `Compression Information | ${await getReadableTitleFromAddress(props)} | Solana`, + }; +} + +export default function CompressionPage(props: Props) { + return ; +} diff --git a/app/address/[address]/layout.tsx b/app/address/[address]/layout.tsx index 153800d6..b3cdb0fd 100644 --- a/app/address/[address]/layout.tsx +++ b/app/address/[address]/layout.tsx @@ -41,11 +41,13 @@ import { useClusterPath } from '@utils/url'; import { MetadataPointer, TokenMetadata } from '@validators/accounts/token-extension' import Link from 'next/link'; import { redirect, useSelectedLayoutSegment } from 'next/navigation'; -import React, { PropsWithChildren } from 'react'; import { create } from 'superstruct'; +import React, { PropsWithChildren, Suspense } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; import useSWRImmutable from 'swr/immutable'; import { Base58EncodedAddress } from 'web3js-experimental'; +import { CompressedNftAccountHeader, CompressedNftCard } from '@/app/components/account/CompressedNftCard'; import { FullTokenInfo, getFullTokenInfo } from '@/app/utils/token-info'; const IDENTICON_WIDTH = 64; @@ -194,7 +196,9 @@ function AddressLayoutInner({ children, params: { address } }: Props) { const infoParsed = info?.data?.data.parsed; const { data: fullTokenInfo, isLoading: isFullTokenInfoLoading } = useSWRImmutable( - infoStatus === FetchStatus.Fetched && infoParsed && isTokenProgramData(infoParsed) && pubkey ? ['get-full-token-info', address, cluster, url] : null, + infoStatus === FetchStatus.Fetched && infoParsed && isTokenProgramData(infoParsed) && pubkey + ? ['get-full-token-info', address, cluster, url] + : null, fetchFullTokenInfo ); @@ -209,13 +213,23 @@ function AddressLayoutInner({ children, params: { address } }: Props) {
- +
{!pubkey ? ( ) : ( - + {children} )} @@ -231,7 +245,17 @@ export default function AddressLayout({ children, params }: Props) { ); } -function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { address: string; account?: Account, tokenInfo?: FullTokenInfo, isTokenInfoLoading: boolean }) { +function AccountHeader({ + address, + account, + tokenInfo, + isTokenInfoLoading, +}: { + address: string; + account?: Account; + tokenInfo?: FullTokenInfo; + isTokenInfoLoading: boolean; +}) { const mintInfo = useMintAccountInfo(address); const parsedData = account?.data.parsed; @@ -316,12 +340,22 @@ function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { ad ); } - return ( + const fallback = ( <>
Details

Account

); + if (account) { + return ( + + + + + + ); + } + return fallback; } function DetailsSections({ @@ -330,7 +364,7 @@ function DetailsSections({ tab, info, tokenInfo, - isTokenInfoLoading + isTokenInfoLoading, }: { children: React.ReactNode; pubkey: PublicKey; @@ -364,7 +398,7 @@ function DetailsSections({ ); } -function InfoSection({ account, tokenInfo }: { account: Account, tokenInfo?: FullTokenInfo }) { +function InfoSection({ account, tokenInfo }: { account: Account; tokenInfo?: FullTokenInfo }) { const parsedData = account.data.parsed; const rawData = account.data.raw; @@ -408,7 +442,14 @@ function InfoSection({ account, tokenInfo }: { account: Account, tokenInfo?: Ful } else if (account.owner.toBase58() === FEATURE_PROGRAM_ID) { return ; } else { - return ; + const fallback = ; + return ( + + + + + + ); } } @@ -441,7 +482,8 @@ export type MoreTabs = | 'anchor-program' | 'anchor-account' | 'entries' - | 'concurrent-merkle-tree'; + | 'concurrent-merkle-tree' + | 'compression'; function MoreSection({ children, tabs }: { children: React.ReactNode; tabs: (JSX.Element | null)[] }) { return ( @@ -483,15 +525,42 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] { } // Add the key for address lookup tables - if (account.data.raw && isAddressLookupTableAccount(account.owner.toBase58() as Base58EncodedAddress, account.data.raw)) { + if ( + account.data.raw && + isAddressLookupTableAccount(account.owner.toBase58() as Base58EncodedAddress, account.data.raw) + ) { tabs.push(...TABS_LOOKUP['address-lookup-table']); } // Add the key for Metaplex NFTs - if (parsedData && (programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') && (parsedData as TokenProgramData).nftData) { + if ( + parsedData && + (programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') && + (parsedData as TokenProgramData).nftData + ) { tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]); } + if (!tabs.find(tab => tab.slug === 'metadata')) { + tabs.push( + { + path: 'metadata', + slug: 'metadata', + title: 'Metadata', + }, + { + path: 'attributes', + slug: 'attributes', + title: 'Attributes', + } + ); + tabs.push({ + path: 'compression', + slug: 'compression', + title: 'Compression', + }); + } + const isNFToken = account && isNFTokenAccount(account); if (isNFToken) { const collection = parseNFTokenCollectionAccount(account); diff --git a/app/address/[address]/metadata/page-client.tsx b/app/address/[address]/metadata/page-client.tsx index ba2f1fae..d53d037a 100644 --- a/app/address/[address]/metadata/page-client.tsx +++ b/app/address/[address]/metadata/page-client.tsx @@ -2,8 +2,9 @@ import { MetaplexMetadataCard } from '@components/account/MetaplexMetadataCard'; import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer'; -import { isTokenProgramData } from '@providers/accounts'; -import React from 'react'; +import React, { Suspense } from 'react'; + +import { LoadingCard } from '@/app/components/common/LoadingCard'; type Props = Readonly<{ params: { @@ -15,11 +16,11 @@ function MetaplexMetadataCardRenderer({ account, onNotFound, }: React.ComponentProps['renderComponent']>) { - const parsedData = account?.data?.parsed; - if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) { - return onNotFound(); - } - return ; + return ( + }> + {} + + ); } export default function MetaplexNFTMetadataPageClient({ params: { address } }: Props) { diff --git a/app/components/account/CompressedNFTInfoCard.tsx b/app/components/account/CompressedNFTInfoCard.tsx new file mode 100644 index 00000000..1b3f97b4 --- /dev/null +++ b/app/components/account/CompressedNFTInfoCard.tsx @@ -0,0 +1,131 @@ +import { Account, useAccountInfo, useFetchAccountInfo } from '@providers/accounts'; +import { ConcurrentMerkleTreeAccount, MerkleTree } from '@solana/spl-account-compression'; +import { PublicKey } from '@solana/web3.js'; +import React from 'react'; + +import { useCluster } from '@/app/providers/cluster'; +import { + CompressedNft, + CompressedNftProof, + useCompressedNft, + useCompressedNftProof, +} from '@/app/providers/compressed-nft'; + +import { Address } from '../common/Address'; +import { TableCardBody } from '../common/TableCardBody'; + +export function CompressedNFTInfoCard({ account, onNotFound }: { account?: Account; onNotFound: () => never }) { + const { url } = useCluster(); + const compressedNft = useCompressedNft({ address: account?.pubkey.toString() ?? '', url }); + const proof = useCompressedNftProof({ address: account?.pubkey.toString() ?? '', url }); + + if (compressedNft && compressedNft.compression.compressed && proof) { + return ; + } + return onNotFound(); +} + +function DasCompressionInfoCard({ proof, compressedNft }: { proof: CompressedNftProof; compressedNft: CompressedNft }) { + const compressedInfo = compressedNft.compression; + const fetchAccountInfo = useFetchAccountInfo(); + const treeAccountInfo = useAccountInfo(compressedInfo.tree); + const treeAddress = new PublicKey(compressedInfo.tree); + + React.useEffect(() => { + fetchAccountInfo(treeAddress, 'raw'); + }, [compressedInfo.tree]); // eslint-disable-line react-hooks/exhaustive-deps + + const root = new PublicKey(proof.root); + const proofVerified = MerkleTree.verify(root.toBuffer(), { + leaf: new PublicKey(compressedNft.compression.asset_hash).toBuffer(), + leafIndex: compressedNft.compression.leaf_id, + proof: proof.proof.map(proofData => new PublicKey(proofData).toBuffer()), + root: root.toBuffer(), + }); + const canopyDepth = + treeAccountInfo && treeAccountInfo.data && treeAccountInfo.data.data.raw + ? ConcurrentMerkleTreeAccount.fromBuffer(treeAccountInfo.data.data.raw).getCanopyDepth() + : 0; + const proofSize = proof.proof.length - canopyDepth; + return ( +
+
+

Compression Info

+
+ + + + Concurrent Merkle Tree + +
+ + + + Current Tree Root {getVerifiedProofPill(proofVerified)} + +
+ + + + Proof Size {getProofSizePill(proofSize)} + {proofSize} + + + Leaf Number + {compressedInfo.leaf_id} + + + Sequence Number of Last Update + {compressedInfo.seq} + + + Compressed Nft Hash + +
+ + + + Creators Hash + +
+ + + + Metadata Hash + +
+ + + +
+ ); +} + +function getVerifiedProofPill(verified: boolean) { + return ( +
+ {`Proof ${ + verified ? '' : 'Not' + } Verified`} +
+ ); +} + +function getProofSizePill(proofSize: number) { + let text: string; + let color = 'bg-dark'; + if (proofSize == 0) { + text = 'No Proof Required'; + } else if (proofSize > 8) { + text = `Composability Hazard`; + color = 'bg-danger-soft'; + } else { + return
; + } + + return ( +
+ {text} +
+ ); +} diff --git a/app/components/account/CompressedNftCard.tsx b/app/components/account/CompressedNftCard.tsx new file mode 100644 index 00000000..6ff1678d --- /dev/null +++ b/app/components/account/CompressedNftCard.tsx @@ -0,0 +1,159 @@ +import { Account } from '@providers/accounts'; +import { PublicKey } from '@solana/web3.js'; +import { createRef, Suspense } from 'react'; +import { ChevronDown, ExternalLink } from 'react-feather'; +import useAsyncEffect from 'use-async-effect'; + +import { useCluster } from '@/app/providers/cluster'; +import { CompressedNft, useCompressedNft, useMetadataJsonLink } from '@/app/providers/compressed-nft'; + +import { Address } from '../common/Address'; +import { InfoTooltip } from '../common/InfoTooltip'; +import { LoadingArtPlaceholder } from '../common/LoadingArtPlaceholder'; +import { ArtContent } from '../common/NFTArt'; +import { TableCardBody } from '../common/TableCardBody'; +import { getCreatorDropdownItems, getIsMutablePill, getVerifiedCollectionPill } from './MetaplexNFTHeader'; +import { UnknownAccountCard } from './UnknownAccountCard'; + +export function CompressedNftCard({ account }: { account: Account }) { + const { url } = useCluster(); + const compressedNft = useCompressedNft({ address: account.pubkey.toString(), url }); + if (!compressedNft) return ; + + const collectionGroup = compressedNft.grouping.find(group => group.group_key === 'collection'); + const updateAuthority = compressedNft.authorities.find(authority => authority.scopes.includes('full'))?.address; + + return ( +
+
+

Overview

+
+ + + Address + +
+ + + + Verified Collection Address + + {collectionGroup ? ( +
+ ) : ( + 'None' + )} + + + + Update Authority + + {updateAuthority ?
: 'None'} + + + + Website + + + {compressedNft.content.links.external_url} + + + + + + Seller Fee + {`${compressedNft.royalty.basis_points / 100}%`} + + +
+ ); +} + +export function CompressedNftAccountHeader({ account }: { account: Account }) { + const { url } = useCluster(); + const compressedNft = useCompressedNft({ address: account.pubkey.toString(), url }); + if (!compressedNft) throw new Error('Compressed NFT not found'); + + if (compressedNft) { + return ( + }> + + + ); + } + return
; +} + +export function CompressedNFTHeader({ compressedNft }: { compressedNft: CompressedNft }) { + const metadataJson = useMetadataJsonLink(compressedNft.content.json_uri); + const dropdownRef = createRef(); + + useAsyncEffect( + async isMounted => { + if (!dropdownRef.current) { + return; + } + const Dropdown = (await import('bootstrap/js/dist/dropdown')).default; + if (!isMounted || !dropdownRef.current) { + return; + } + return new Dropdown(dropdownRef.current); + }, + dropdown => { + if (dropdown) { + dropdown.dispose(); + } + }, + [dropdownRef] + ); + + return ( +
+
+ +
+
+ {
Metaplex Compressed NFT
} +
+

+ {compressedNft.content.metadata.name !== '' + ? compressedNft.content.metadata.name + : 'No NFT name was found'} +

+ {getVerifiedCollectionPill()} +
+

+ {compressedNft.content.metadata.symbol !== '' + ? compressedNft.content.metadata.symbol + : 'No Symbol was found'} +

+
{getCompressedNftPill()}
+
{getIsMutablePill(compressedNft.mutable)}
+
+ +
{getCreatorDropdownItems(compressedNft.creators)}
+
+
+
+ ); +} + +function getCompressedNftPill() { + const onchainVerifiedToolTip = + 'This NFT does not have a corresponding account, but uses verified ledger data to allow for transfers and trades. The existence of this tag ensures that the compressed NFT is verifiably up-to-date with the chain.'; + return ( +
+ {'Compressed'} + +
+ ); +} diff --git a/app/components/account/ConcurrentMerkleTreeCard.tsx b/app/components/account/ConcurrentMerkleTreeCard.tsx index 24090bf8..3a8981c9 100644 --- a/app/components/account/ConcurrentMerkleTreeCard.tsx +++ b/app/components/account/ConcurrentMerkleTreeCard.tsx @@ -26,70 +26,68 @@ export function ConcurrentMerkleTreeCard({ data }: { data: Buffer }) {
-
- - - Authority - -
- - - - Creation Slot - - - - - - Max Depth - - {treeHeight} - - - - Max Buffer Size - - {maxBufferSize} - - - - Canopy Depth - - {canopyDepth} - - - - Current Sequence Number - - {seq.toString()} - - - - Current Root - -
- - - - Current Number of Leaves - - {rightMostIndex} - - - - Remaining Leaves - - {Math.pow(2, treeHeight) - rightMostIndex} - - - - Max Possible Leaves - - {Math.pow(2, treeHeight)} - - - -
+ + + Authority + +
+ + + + Creation Slot + + + + + + Max Depth + + {treeHeight} + + + + Max Buffer Size + + {maxBufferSize} + + + + Canopy Depth + + {canopyDepth} + + + + Current Sequence Number + + {seq.toString()} + + + + Current Root + +
+ + + + Current Number of Leaves + + {rightMostIndex} + + + + Remaining Leaves + + {Math.pow(2, treeHeight) - rightMostIndex} + + + + Max Possible Leaves + + {Math.pow(2, treeHeight)} + + +
); diff --git a/app/components/account/MetaplexMetadataCard.tsx b/app/components/account/MetaplexMetadataCard.tsx index 1b0be0de..1a4df11e 100644 --- a/app/components/account/MetaplexMetadataCard.tsx +++ b/app/components/account/MetaplexMetadataCard.tsx @@ -1,7 +1,25 @@ -import { NFTData } from '@providers/accounts'; +import { Account, NFTData } from '@providers/accounts'; +import { isTokenProgramData } from '@providers/accounts'; import ReactJson from 'react-json-view'; -export function MetaplexMetadataCard({ nftData }: { nftData: NFTData }) { +import { useCluster } from '@/app/providers/cluster'; +import { CompressedNft, useCompressedNft, useMetadataJsonLink } from '@/app/providers/compressed-nft'; + +export function MetaplexMetadataCard({ account, onNotFound }: { account?: Account; onNotFound: () => never }) { + const { url } = useCluster(); + const compressedNft = useCompressedNft({ address: account?.pubkey.toString() ?? '', url }); + + const parsedData = account?.data?.parsed; + if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) { + if (compressedNft && compressedNft.compression.compressed) { + return ; + } + return onNotFound(); + } + return ; +} + +function NormalMetadataCard({ nftData }: { nftData: NFTData }) { return ( <>
@@ -20,3 +38,24 @@ export function MetaplexMetadataCard({ nftData }: { nftData: NFTData }) { ); } + +function CompressedMetadataCard({ compressedNft }: { compressedNft: CompressedNft }) { + const metadataJson = useMetadataJsonLink(compressedNft.content.json_uri); + return ( + <> +
+
+
+
+

Metaplex Metadata

+
+
+
+ +
+ +
+
+ + ); +} diff --git a/app/components/account/MetaplexNFTAttributesCard.tsx b/app/components/account/MetaplexNFTAttributesCard.tsx index 4c70e783..46728ade 100644 --- a/app/components/account/MetaplexNFTAttributesCard.tsx +++ b/app/components/account/MetaplexNFTAttributesCard.tsx @@ -1,20 +1,37 @@ import { ErrorCard } from '@components/common/ErrorCard'; import { LoadingCard } from '@components/common/LoadingCard'; -import { NFTData } from '@providers/accounts'; +import { Account, isTokenProgramData } from '@providers/accounts'; import React from 'react'; +import { useCluster } from '@/app/providers/cluster'; +import { useCompressedNft } from '@/app/providers/compressed-nft'; + interface Attribute { trait_type: string; value: string; } -export function MetaplexNFTAttributesCard({ nftData }: { nftData: NFTData }) { +export function MetaplexNFTAttributesCard({ account, onNotFound }: { account?: Account; onNotFound: () => never }) { + const { url } = useCluster(); + const compressedNft = useCompressedNft({ address: account?.pubkey.toString() ?? '', url }); + + const parsedData = account?.data?.parsed; + if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) { + if (compressedNft && compressedNft.compression.compressed) { + return ; + } + return onNotFound(); + } + return ; +} + +function NormalMetaplexNFTAttributesCard({ metadataUri }: { metadataUri: string }) { const [attributes, setAttributes] = React.useState([]); const [status, setStatus] = React.useState<'loading' | 'success' | 'error'>('loading'); async function fetchMetadataAttributes() { try { - const response = await fetch(nftData.metadata.data.uri); + const response = await fetch(metadataUri); const metadata = await response.json(); // Verify if the attributes value is an array diff --git a/app/components/account/MetaplexNFTHeader.tsx b/app/components/account/MetaplexNFTHeader.tsx index c1e8e27b..aa6350c4 100644 --- a/app/components/account/MetaplexNFTHeader.tsx +++ b/app/components/account/MetaplexNFTHeader.tsx @@ -82,7 +82,7 @@ export function MetaplexNFTHeader({ nftData, address }: { nftData: NFTData; addr } type Creator = programs.metadata.Creator; -function getCreatorDropdownItems(creators: Creator[] | null) { +export function getCreatorDropdownItems(creators: Creator[] | null) { const CreatorHeader = () => { const creatorTooltip = 'Verified creators signed the metadata associated with this NFT when it was created.'; @@ -170,11 +170,11 @@ function getSaleTypePill(hasPrimarySaleHappened: boolean) { ); } -function getIsMutablePill(isMutable: boolean) { +export function getIsMutablePill(isMutable: boolean) { return {`${isMutable ? 'Mutable' : 'Immutable'}`}; } -function getVerifiedCollectionPill() { +export function getVerifiedCollectionPill() { const onchainVerifiedToolTip = 'This NFT has been verified as a member of an on-chain collection. This tag guarantees authenticity.'; return ( diff --git a/app/providers/accounts/index.tsx b/app/providers/accounts/index.tsx index 4fa4e639..c85fdeef 100644 --- a/app/providers/accounts/index.tsx +++ b/app/providers/accounts/index.tsx @@ -62,7 +62,7 @@ export function isTokenProgramData(data: { program: string }): data is TokenProg try { assertIsTokenProgram(data.program); return true; - } catch(e) { + } catch (e) { return false; } } diff --git a/app/providers/compressed-nft.tsx b/app/providers/compressed-nft.tsx new file mode 100644 index 00000000..3aff1aa8 --- /dev/null +++ b/app/providers/compressed-nft.tsx @@ -0,0 +1,203 @@ +type CacheType

= Record< + string, + void | { __type: 'promise'; promise: Promise } | { __type: 'result'; result: P } +>; + +const cachedNftPromises: CacheType = {}; + +const cachedPromises = { + compressedNft: {} as CacheType, + compressedNftProof: {} as CacheType, + nftMetadataJson: {} as CacheType, +}; + +function makeCache( + cacheName: keyof typeof cachedPromises, + keygen: (params: Params) => string, + action: (params: Params) => Promise +): (params: Params) => CacheValueType { + return (params: Params) => { + const key = keygen(params); + const cacheEntry = cachedPromises[cacheName][key]; + + if (cacheEntry === undefined) { + const promise = action(params) + .then((value: CacheValueType) => { + cachedPromises[cacheName][key] = { + __type: 'result', + result: value, + }; + }) + .catch(_ => { + cachedNftPromises[key] = { __type: 'result', result: null }; + }); + cachedPromises[cacheName][key] = { + __type: 'promise', + promise, + }; + throw promise; + } else if (cacheEntry.__type === 'promise') { + throw cacheEntry.promise; + } + return cacheEntry.result; + }; +} + +export const useMetadataJsonLink = makeCache( + 'nftMetadataJson', + (url: string) => url, + async (url: string) => { + return fetch(url).then(response => response.json()); + } +); + +export const useCompressedNft = makeCache<{ address: string; url: string }, CompressedNft | null>( + 'compressedNft', + ({ address, url }) => `${address}-${url}`, + async ({ address, url }) => { + return fetch(`${url}`, { + body: JSON.stringify({ + id: address, + jsonrpc: '2.0', + method: 'getAsset', + params: { + id: address, + }, + }), + method: 'POST', + }) + .then(response => response.json()) + .then((response: DasApiResponse) => { + if ('error' in response) { + throw new Error(response.error.message); + } + + return response.result; + }); + } +); + +export const useCompressedNftProof = makeCache<{ address: string; url: string }, CompressedNftProof | null>( + 'compressedNftProof', + ({ address, url }) => `proof-${address}-${url}`, + async ({ address, url }) => { + return fetch(`${url}`, { + body: JSON.stringify({ + id: address, + jsonrpc: '2.0', + method: 'getAssetProof', + params: { + id: address, + }, + }), + method: 'POST', + }) + .then(response => response.json()) + .then((response: DasApiResponse) => { + if ('error' in response) { + throw new Error(response.error.message); + } + + return response.result; + }); + } +); + +type DasResponseTypes = CompressedNft | CompressedNftProof; +export type DasApiResponse = + | { + jsonrpc: string; + id: string; + result: T; + } + | { + jsonrpc: string; + id: string; + error: { + code: number; + message: string; + }; + }; + +export type CompressedNft = { + interface: string; + id: string; + content: { + $schema: string; + json_uri: string; + files: { + uri: string; + cdn_uri: string; + mime: string; + }[]; + metadata: { + attributes: { + value: string; + trait_type: string; + }[]; + description: string; + name: string; + symbol: string; + token_standard: string; + }; + links: { + external_url: string; + image: string; + }; + }; + authorities: { + address: string; + scopes: string[]; + }[]; + compression: { + eligible: boolean; + compressed: boolean; + data_hash: string; + creator_hash: string; + asset_hash: string; + tree: string; + seq: number; + leaf_id: number; + }; + grouping: { + group_key: string; + group_value: string; + }[]; + royalty: { + royalty_model: string; + target: null; + percent: number; + basis_points: number; + primary_sale_happened: boolean; + locked: boolean; + }; + creators: [ + { + address: string; + share: number; + verified: boolean; + } + ]; + ownership: { + frozen: boolean; + delegated: boolean; + delegate: string | null; + ownership_model: string; + owner: string; + }; + supply: { + print_max_supply: number; + print_current_supply: number; + edition_nonce: number | null; + }; + mutable: boolean; + burnt: boolean; +}; + +export type CompressedNftProof = { + root: string; + proof: string[]; + node_index: number; + leaf: string; + tree_id: string; +};