diff --git a/app/address/[address]/compression/page-client.tsx b/app/address/[address]/compression/page-client.tsx new file mode 100644 index 00000000..1e0e066a --- /dev/null +++ b/app/address/[address]/compression/page-client.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { MetaplexNFTAttributesCard } from '@components/account/MetaplexNFTAttributesCard'; +import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer'; +import React, { Suspense } from 'react'; + +import { LoadingCard } from '@/app/components/common/LoadingCard'; +import { CompressedNFTInfoCard } from '@/app/components/account/CompressedNFTInfoCard'; + +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 ba1c4c50..ea1018d3 100644 --- a/app/address/[address]/layout.tsx +++ b/app/address/[address]/layout.tsx @@ -467,7 +467,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 ( @@ -538,6 +539,11 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] { title: 'Attributes', } ); + tabs.push({ + path: 'compression', + slug: 'compression', + title: 'Compression', + }); } const isNFToken = account && isNFTokenAccount(account); 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/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 9034438f..1a4df11e 100644 --- a/app/components/account/MetaplexMetadataCard.tsx +++ b/app/components/account/MetaplexMetadataCard.tsx @@ -53,7 +53,7 @@ function CompressedMetadataCard({ compressedNft }: { compressedNft: CompressedNf
- +
diff --git a/app/providers/compressed-nft.tsx b/app/providers/compressed-nft.tsx index a8b2f064..3aff1aa8 100644 --- a/app/providers/compressed-nft.tsx +++ b/app/providers/compressed-nft.tsx @@ -7,6 +7,7 @@ const cachedNftPromises: CacheType = {}; const cachedPromises = { compressedNft: {} as CacheType, + compressedNftProof: {} as CacheType, nftMetadataJson: {} as CacheType, }; @@ -66,21 +67,48 @@ export const useCompressedNft = makeCache<{ address: string; url: string }, Comp method: 'POST', }) .then(response => response.json()) - .then((response: DasApiResponse) => { + .then((response: DasApiResponse) => { if ('error' in response) { throw new Error(response.error.message); } - return response.result as CompressedNft; + return response.result; }); } ); -export type DasApiResponse = +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: CompressedNft; + result: T; } | { jsonrpc: string; @@ -165,3 +193,11 @@ export type CompressedNft = { mutable: boolean; burnt: boolean; }; + +export type CompressedNftProof = { + root: string; + proof: string[]; + node_index: number; + leaf: string; + tree_id: string; +};