diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 0ff8fab3c..da41ceef9 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -12,6 +12,7 @@ "@bonfida/bot": "^0.5.3", "@cloudflare/stream-react": "^1.2.0", "@metamask/jazzicon": "^2.0.0", + "@metaplex/js": "1.2.0", "@project-serum/serum": "^0.13.60", "@react-hook/debounce": "^4.0.0", "@sentry/react": "^6.13.3", @@ -3499,6 +3500,83 @@ "color-name": "^1.0.0" } }, + "node_modules/@metaplex/js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@metaplex/js/-/js-1.2.0.tgz", + "integrity": "sha512-qbfie0Zwish72Ry3UdA5YVgNzLYqLm86EOsnBos9UNkTRr+Yb2/Xh/l5cgws6lUNpnTLm9c8PaLRmtRvuLD3tQ==", + "dependencies": { + "@solana/spl-token": "^0.1.8", + "@solana/web3.js": "^1.24.1", + "@types/bs58": "^4.0.1", + "axios": "^0.21.4", + "bn.js": "^5.2.0", + "borsh": "^0.4.0", + "bs58": "^4.0.1", + "buffer": "^6.0.3", + "crypto-hash": "^1.3.0", + "form-data": "^4.0.0" + } + }, + "node_modules/@metaplex/js/node_modules/@solana/spl-token": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@solana/spl-token/-/spl-token-0.1.8.tgz", + "integrity": "sha512-LZmYCKcPQDtJgecvWOgT/cnoIQPWjdH+QVyzPcFvyDUiT0DiRjZaam4aqNUyvchLFhzgunv3d9xOoyE34ofdoQ==", + "dependencies": { + "@babel/runtime": "^7.10.5", + "@solana/web3.js": "^1.21.0", + "bn.js": "^5.1.0", + "buffer": "6.0.3", + "buffer-layout": "^1.2.0", + "dotenv": "10.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@metaplex/js/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@metaplex/js/node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@metaplex/js/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", @@ -28902,6 +28980,62 @@ } } }, + "@metaplex/js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@metaplex/js/-/js-1.2.0.tgz", + "integrity": "sha512-qbfie0Zwish72Ry3UdA5YVgNzLYqLm86EOsnBos9UNkTRr+Yb2/Xh/l5cgws6lUNpnTLm9c8PaLRmtRvuLD3tQ==", + "requires": { + "@solana/spl-token": "^0.1.8", + "@solana/web3.js": "^1.24.1", + "@types/bs58": "^4.0.1", + "axios": "^0.21.4", + "bn.js": "^5.2.0", + "borsh": "^0.4.0", + "bs58": "^4.0.1", + "buffer": "^6.0.3", + "crypto-hash": "^1.3.0", + "form-data": "^4.0.0" + }, + "dependencies": { + "@solana/spl-token": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@solana/spl-token/-/spl-token-0.1.8.tgz", + "integrity": "sha512-LZmYCKcPQDtJgecvWOgT/cnoIQPWjdH+QVyzPcFvyDUiT0DiRjZaam4aqNUyvchLFhzgunv3d9xOoyE34ofdoQ==", + "requires": { + "@babel/runtime": "^7.10.5", + "@solana/web3.js": "^1.21.0", + "bn.js": "^5.1.0", + "buffer": "6.0.3", + "buffer-layout": "^1.2.0", + "dotenv": "10.0.0" + } + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, "@nodelib/fs.scandir": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", diff --git a/explorer/package.json b/explorer/package.json index a0e37f670..72646a333 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -7,6 +7,7 @@ "@bonfida/bot": "^0.5.3", "@cloudflare/stream-react": "^1.2.0", "@metamask/jazzicon": "^2.0.0", + "@metaplex/js": "1.2.0", "@project-serum/serum": "^0.13.60", "@react-hook/debounce": "^4.0.0", "@sentry/react": "^6.13.3", diff --git a/explorer/src/components/account/MetaplexNFTHeader.tsx b/explorer/src/components/account/MetaplexNFTHeader.tsx index 7275dc193..c3d643e6f 100644 --- a/explorer/src/components/account/MetaplexNFTHeader.tsx +++ b/explorer/src/components/account/MetaplexNFTHeader.tsx @@ -1,11 +1,11 @@ import "bootstrap/dist/js/bootstrap.min.js"; import { NFTData } from "providers/accounts"; -import { Creator } from "metaplex/classes"; -import { ArtContent } from "metaplex/Art/Art"; +import { Creator } from "@metaplex/js"; +import { ArtContent } from "components/common/NFTArt"; import { InfoTooltip } from "components/common/InfoTooltip"; -import { EditionData } from "providers/accounts/utils/metadataHelpers"; import { clusterPath } from "utils/url"; import { Link } from "react-router-dom"; +import { EditionInfo } from "providers/accounts/utils/getEditionInfo"; export function NFTHeader({ nftData, @@ -29,7 +29,7 @@ export function NFTHeader({ ? metadata.data.name : "No NFT name was found"} - {getEditionPill(nftData.editionData)} + {getEditionPill(nftData.editionInfo)}

{metadata.data.symbol !== "" @@ -127,9 +127,9 @@ function getCreatorDropdownItems(creators: Creator[] | null) { ); } -function getEditionPill(editionData?: EditionData) { - const masterEdition = editionData?.masterEdition; - const edition = editionData?.edition; +function getEditionPill(editionInfo: EditionInfo) { + const masterEdition = editionInfo.masterEdition; + const edition = editionInfo.edition; return (
diff --git a/explorer/src/components/account/TokenAccountSection.tsx b/explorer/src/components/account/TokenAccountSection.tsx index 1decf1770..7e9d23c4c 100644 --- a/explorer/src/components/account/TokenAccountSection.tsx +++ b/explorer/src/components/account/TokenAccountSection.tsx @@ -19,7 +19,7 @@ import { Copyable } from "components/common/Copyable"; import { CoingeckoStatus, useCoinGecko } from "utils/coingecko"; import { displayTimestampWithoutDate } from "utils/date"; import { LoadingCard } from "components/common/LoadingCard"; -import { toPublicKey } from "metaplex/ids"; +import { PublicKey } from "@solana/web3.js"; const getEthAddress = (link?: string) => { let address = ""; @@ -314,23 +314,23 @@ function NonFungibleTokenMintAccountCard({
- {nftData?.editionData?.masterEdition?.maxSupply && ( + {nftData.editionInfo.masterEdition?.maxSupply && ( Max Total Supply - {nftData.editionData.masterEdition.maxSupply.toNumber() === 0 + {nftData.editionInfo.masterEdition.maxSupply.toNumber() === 0 ? 1 - : nftData.editionData.masterEdition.maxSupply.toNumber()} + : nftData.editionInfo.masterEdition.maxSupply.toNumber()} )} - {nftData?.editionData?.masterEdition?.supply && ( + {nftData?.editionInfo.masterEdition?.supply && ( Current Supply - {nftData.editionData.masterEdition.supply.toNumber() === 0 + {nftData.editionInfo.masterEdition.supply.toNumber() === 0 ? 1 - : nftData.editionData.masterEdition.supply.toNumber()} + : nftData.editionInfo.masterEdition.supply.toNumber()} )} @@ -346,7 +346,7 @@ function NonFungibleTokenMintAccountCard({ Update Authority
diff --git a/explorer/src/metaplex/Art/Art.tsx b/explorer/src/components/common/NFTArt.tsx similarity index 65% rename from explorer/src/metaplex/Art/Art.tsx rename to explorer/src/components/common/NFTArt.tsx index ccc6e2a0a..1f200f075 100644 --- a/explorer/src/metaplex/Art/Art.tsx +++ b/explorer/src/components/common/NFTArt.tsx @@ -1,13 +1,15 @@ import { useCallback, useEffect, useState } from "react"; -import { MetadataCategory, MetadataFile } from "../types"; -import { pubkeyToString } from "../utils"; -import { useCachedImage, useExtendedArt } from "./useArt"; import { Stream, StreamPlayerApi } from "@cloudflare/stream-react"; import { PublicKey } from "@solana/web3.js"; -import { getLast } from "../utils"; -import { Metadata } from "metaplex/classes"; +import { + MetadataData, + MetadataJson, + MetaDataJsonCategory, + MetadataJsonFile, +} from "@metaplex/js"; import ContentLoader from "react-content-loader"; import ErrorLogo from "img/logos-solana/dark-solana-logo.svg"; +import { getLast, pubkeyToString } from "utils"; const MAX_TIME_LOADING_IMAGE = 5000; /* 5 seconds */ @@ -90,7 +92,7 @@ const VideoArtContent = ({ animationURL, active, }: { - files?: (MetadataFile | string)[]; + files?: (MetadataJsonFile | string)[]; uri?: string; animationURL?: string; active?: boolean; @@ -168,7 +170,7 @@ const HTMLContent = ({ files, }: { animationUrl?: string; - files?: (MetadataFile | string)[]; + files?: (MetadataJsonFile | string)[]; }) => { const [loaded, setLoaded] = useState(false); const htmlURL = @@ -207,13 +209,13 @@ export const ArtContent = ({ animationURL, files, }: { - metadata: Metadata; - category?: MetadataCategory; + metadata: MetadataData; + category?: MetaDataJsonCategory; active?: boolean; pubkey?: PublicKey | string; uri?: string; animationURL?: string; - files?: (MetadataFile | string)[]; + files?: (MetadataJsonFile | string)[]; }) => { const id = pubkeyToString(pubkey); @@ -261,3 +263,113 @@ export const ArtContent = ({
); }; + +enum ArtFetchStatus { + ReadyToFetch, + Fetching, + FetchFailed, + FetchSucceeded, +} + +const cachedImages = new Map(); +export const useCachedImage = (uri: string) => { + const [cachedBlob, setCachedBlob] = useState(undefined); + const [fetchStatus, setFetchStatus] = useState( + ArtFetchStatus.ReadyToFetch + ); + + useEffect(() => { + if (!uri) { + return; + } + + if (fetchStatus === ArtFetchStatus.FetchFailed) { + setCachedBlob(uri); + return; + } + + const result = cachedImages.get(uri); + if (result) { + setCachedBlob(result); + return; + } + + if (fetchStatus === ArtFetchStatus.ReadyToFetch) { + (async () => { + setFetchStatus(ArtFetchStatus.Fetching); + let response: Response; + try { + response = await fetch(uri, { cache: "force-cache" }); + } catch { + try { + response = await fetch(uri, { cache: "reload" }); + } catch { + if (uri?.startsWith("http")) { + setCachedBlob(uri); + } + setFetchStatus(ArtFetchStatus.FetchFailed); + return; + } + } + + const blob = await response.blob(); + const blobURI = URL.createObjectURL(blob); + cachedImages.set(uri, blobURI); + setCachedBlob(blobURI); + setFetchStatus(ArtFetchStatus.FetchSucceeded); + })(); + } + }, [uri, setCachedBlob, fetchStatus, setFetchStatus]); + + return { cachedBlob }; +}; + +export const useExtendedArt = (id: string, metadata: MetadataData) => { + const [data, setData] = useState(); + + useEffect(() => { + if (id && !data) { + if (metadata.data.uri) { + const uri = metadata.data.uri; + + const processJson = (extended: any) => { + if (!extended || extended?.properties?.files?.length === 0) { + return; + } + + if (extended?.image) { + extended.image = extended.image.startsWith("http") + ? extended.image + : `${metadata.data.uri}/${extended.image}`; + } + + return extended; + }; + + try { + fetch(uri) + .then(async (_) => { + try { + const data = await _.json(); + try { + localStorage.setItem(uri, JSON.stringify(data)); + } catch { + // ignore + } + setData(processJson(data)); + } catch { + return undefined; + } + }) + .catch(() => { + return undefined; + }); + } catch (ex) { + console.error(ex); + } + } + } + }, [id, data, setData, metadata.data.uri]); + + return { data }; +}; diff --git a/explorer/src/metaplex/Art/useArt.ts b/explorer/src/metaplex/Art/useArt.ts deleted file mode 100644 index 72367796b..000000000 --- a/explorer/src/metaplex/Art/useArt.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { IMetadataExtension, Metadata } from "metaplex/classes"; -import { StringPublicKey } from "metaplex/types"; -import { useEffect, useState } from "react"; - -enum ArtFetchStatus { - ReadyToFetch, - Fetching, - FetchFailed, - FetchSucceeded, -} - -const cachedImages = new Map(); -export const useCachedImage = (uri: string) => { - const [cachedBlob, setCachedBlob] = useState(undefined); - const [fetchStatus, setFetchStatus] = useState( - ArtFetchStatus.ReadyToFetch - ); - - useEffect(() => { - if (!uri) { - return; - } - - if (fetchStatus === ArtFetchStatus.FetchFailed) { - setCachedBlob(uri); - return; - } - - const result = cachedImages.get(uri); - if (result) { - setCachedBlob(result); - return; - } - - if (fetchStatus === ArtFetchStatus.ReadyToFetch) { - (async () => { - setFetchStatus(ArtFetchStatus.Fetching); - let response: Response; - try { - response = await fetch(uri, { cache: "force-cache" }); - } catch { - try { - response = await fetch(uri, { cache: "reload" }); - } catch { - if (uri?.startsWith("http")) { - setCachedBlob(uri); - } - setFetchStatus(ArtFetchStatus.FetchFailed); - return; - } - } - - const blob = await response.blob(); - const blobURI = URL.createObjectURL(blob); - cachedImages.set(uri, blobURI); - setCachedBlob(blobURI); - setFetchStatus(ArtFetchStatus.FetchSucceeded); - })(); - } - }, [uri, setCachedBlob, fetchStatus, setFetchStatus]); - - return { cachedBlob }; -}; - -export const useExtendedArt = (id: StringPublicKey, metadata: Metadata) => { - const [data, setData] = useState(); - - useEffect(() => { - if (id && !data) { - if (metadata.data.uri) { - const uri = metadata.data.uri; - - const processJson = (extended: any) => { - if (!extended || extended?.properties?.files?.length === 0) { - return; - } - - if (extended?.image) { - extended.image = extended.image.startsWith("http") - ? extended.image - : `${metadata.data.uri}/${extended.image}`; - } - - return extended; - }; - - try { - fetch(uri) - .then(async (_) => { - try { - const data = await _.json(); - try { - localStorage.setItem(uri, JSON.stringify(data)); - } catch { - // ignore - } - setData(processJson(data)); - } catch { - return undefined; - } - }) - .catch(() => { - return undefined; - }); - } catch (ex) { - console.error(ex); - } - } - } - }, [id, data, setData, metadata.data.uri]); - - return { data }; -}; diff --git a/explorer/src/metaplex/classes.ts b/explorer/src/metaplex/classes.ts deleted file mode 100644 index f5f110d33..000000000 --- a/explorer/src/metaplex/classes.ts +++ /dev/null @@ -1,296 +0,0 @@ -/* - Taken from: https://github.com/metaplex-foundation/metaplex/blob/master/js/packages/common/src/actions/metadata.ts -*/ - -import BN from "bn.js"; -import { - StringPublicKey, - EDITION_MARKER_BIT_SIZE, - MetadataKey, - FileOrString, - MetadataCategory, - MetaplexKey, -} from "./types"; - -export class MasterEditionV1 { - key: MetadataKey; - supply: BN; - maxSupply?: BN; - /// Can be used to mint tokens that give one-time permission to mint a single limited edition. - printingMint: StringPublicKey; - /// If you don't know how many printing tokens you are going to need, but you do know - /// you are going to need some amount in the future, you can use a token from this mint. - /// Coming back to token metadata with one of these tokens allows you to mint (one time) - /// any number of printing tokens you want. This is used for instance by Auction Manager - /// with participation NFTs, where we dont know how many people will bid and need participation - /// printing tokens to redeem, so we give it ONE of these tokens to use after the auction is over, - /// because when the auction begins we just dont know how many printing tokens we will need, - /// but at the end we will. At the end it then burns this token with token-metadata to - /// get the printing tokens it needs to give to bidders. Each bidder then redeems a printing token - /// to get their limited editions. - oneTimePrintingAuthorizationMint: StringPublicKey; - - constructor(args: { - key: MetadataKey; - supply: BN; - maxSupply?: BN; - printingMint: StringPublicKey; - oneTimePrintingAuthorizationMint: StringPublicKey; - }) { - this.key = MetadataKey.MasterEditionV1; - this.supply = args.supply; - this.maxSupply = args.maxSupply; - this.printingMint = args.printingMint; - this.oneTimePrintingAuthorizationMint = - args.oneTimePrintingAuthorizationMint; - } -} - -export class MasterEditionV2 { - key: MetadataKey; - supply: BN; - maxSupply?: BN; - - constructor(args: { key: MetadataKey; supply: BN; maxSupply?: BN }) { - this.key = MetadataKey.MasterEditionV2; - this.supply = args.supply; - this.maxSupply = args.maxSupply; - } -} - -export class EditionMarker { - key: MetadataKey; - ledger: number[]; - - constructor(args: { key: MetadataKey; ledger: number[] }) { - this.key = MetadataKey.EditionMarker; - this.ledger = args.ledger; - } - - editionTaken(edition: number) { - const editionOffset = edition % EDITION_MARKER_BIT_SIZE; - const indexOffset = Math.floor(editionOffset / 8); - - if (indexOffset > 30) { - throw Error("bad index for edition"); - } - - const positionInBitsetFromRight = 7 - (editionOffset % 8); - - const mask = Math.pow(2, positionInBitsetFromRight); - - const appliedMask = this.ledger[indexOffset] & mask; - - return appliedMask !== 0; - } -} - -export class Edition { - key: MetadataKey; - /// Points at MasterEdition struct - parent: StringPublicKey; - /// Starting at 0 for master record, this is incremented for each edition minted. - edition: BN; - - constructor(args: { - key: MetadataKey; - parent: StringPublicKey; - edition: BN; - }) { - this.key = MetadataKey.EditionV1; - this.parent = args.parent; - this.edition = args.edition; - } -} -export class Creator { - address: StringPublicKey; - verified: boolean; - share: number; - - constructor(args: { - address: StringPublicKey; - verified: boolean; - share: number; - }) { - this.address = args.address; - this.verified = args.verified; - this.share = args.share; - } -} - -export class Data { - name: string; - symbol: string; - uri: string; - sellerFeeBasisPoints: number; - creators: Creator[] | null; - constructor(args: { - name: string; - symbol: string; - uri: string; - sellerFeeBasisPoints: number; - creators: Creator[] | null; - }) { - this.name = args.name; - this.symbol = args.symbol; - this.uri = args.uri; - this.sellerFeeBasisPoints = args.sellerFeeBasisPoints; - this.creators = args.creators; - } -} - -export class Metadata { - key: MetadataKey; - updateAuthority: StringPublicKey; - mint: StringPublicKey; - data: Data; - primarySaleHappened: boolean; - isMutable: boolean; - editionNonce: number | null; - - constructor(args: { - updateAuthority: StringPublicKey; - mint: StringPublicKey; - data: Data; - primarySaleHappened: boolean; - isMutable: boolean; - editionNonce: number | null; - }) { - this.key = MetadataKey.MetadataV1; - this.updateAuthority = args.updateAuthority; - this.mint = args.mint; - this.data = args.data; - this.primarySaleHappened = args.primarySaleHappened; - this.isMutable = args.isMutable; - this.editionNonce = args.editionNonce; - } -} - -export interface IMetadataExtension { - name: string; - symbol: string; - - creators: Creator[] | null; - description: string; - // preview image absolute URI - image: string; - animation_url?: string; - - // stores link to item on meta - external_url: string; - - seller_fee_basis_points: number; - - properties: { - files?: FileOrString[]; - category: MetadataCategory; - maxSupply?: number; - creators?: { - address: string; - shares: number; - }[]; - }; -} - -export const METADATA_SCHEMA = new Map([ - [ - MasterEditionV1, - { - kind: "struct", - fields: [ - ["key", "u8"], - ["supply", "u64"], - ["maxSupply", { kind: "option", type: "u64" }], - ["printingMint", "pubkeyAsString"], - ["oneTimePrintingAuthorizationMint", "pubkeyAsString"], - ], - }, - ], - [ - MasterEditionV2, - { - kind: "struct", - fields: [ - ["key", "u8"], - ["supply", "u64"], - ["maxSupply", { kind: "option", type: "u64" }], - ], - }, - ], - [ - Edition, - { - kind: "struct", - fields: [ - ["key", "u8"], - ["parent", "pubkeyAsString"], - ["edition", "u64"], - ], - }, - ], - [ - Data, - { - kind: "struct", - fields: [ - ["name", "string"], - ["symbol", "string"], - ["uri", "string"], - ["sellerFeeBasisPoints", "u16"], - ["creators", { kind: "option", type: [Creator] }], - ], - }, - ], - [ - Creator, - { - kind: "struct", - fields: [ - ["address", "pubkeyAsString"], - ["verified", "u8"], - ["share", "u8"], - ], - }, - ], - [ - Metadata, - { - kind: "struct", - fields: [ - ["key", "u8"], - ["updateAuthority", "pubkeyAsString"], - ["mint", "pubkeyAsString"], - ["data", Data], - ["primarySaleHappened", "u8"], // bool - ["isMutable", "u8"], // bool - ], - }, - ], - [ - EditionMarker, - { - kind: "struct", - fields: [ - ["key", "u8"], - ["ledger", [31]], - ], - }, - ], -]); - -export class WhitelistedCreator { - key: MetaplexKey = MetaplexKey.WhitelistedCreatorV1; - address: StringPublicKey; - activated: boolean = true; - - // Populated from name service - twitter?: string; - name?: string; - image?: string; - description?: string; - - constructor(args: { address: string; activated: boolean }) { - this.address = args.address; - this.activated = args.activated; - } -} diff --git a/explorer/src/metaplex/ids.ts b/explorer/src/metaplex/ids.ts deleted file mode 100644 index 705158f34..000000000 --- a/explorer/src/metaplex/ids.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - Taken from: https://github.com/metaplex-foundation/metaplex/blob/master/js/packages/common/src/utils/ids.ts -*/ - -import { PublicKey, AccountInfo } from "@solana/web3.js"; - -export type StringPublicKey = string; - -export class LazyAccountInfoProxy { - executable: boolean = false; - owner: StringPublicKey = ""; - lamports: number = 0; - - get data() { - // - return undefined as unknown as T; - } -} - -export interface LazyAccountInfo { - executable: boolean; - owner: StringPublicKey; - lamports: number; - data: [string, string]; -} - -const PubKeysInternedMap = new Map(); - -export const toPublicKey = (key: string | PublicKey) => { - if (typeof key !== "string") { - return key; - } - - let result = PubKeysInternedMap.get(key); - if (!result) { - result = new PublicKey(key); - PubKeysInternedMap.set(key, result); - } - - return result; -}; - -export interface PublicKeyStringAndAccount { - pubkey: string; - account: AccountInfo; -} - -export const SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID = new PublicKey( - "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" -); - -export const BPF_UPGRADE_LOADER_ID = new PublicKey( - "BPFLoaderUpgradeab1e11111111111111111111111" -); - -export const MEMO_ID = new PublicKey( - "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" -); - -export const METADATA_PROGRAM_ID = - "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" as StringPublicKey; - -export const VAULT_ID = - "vau1zxA2LbssAUEF7Gpw91zMM1LvXrvpzJtmZ58rPsn" as StringPublicKey; - -export const AUCTION_ID = - "auctxRXPeJoc4817jDhf4HbjnhEcr1cCXenosMhK5R8" as StringPublicKey; - -export const METAPLEX_ID = - "p1exdMJcjVao65QdewkaZRUnU6VPSXhus9n2GzWfh98" as StringPublicKey; - -export const SYSTEM = new PublicKey("11111111111111111111111111111111"); diff --git a/explorer/src/metaplex/types.ts b/explorer/src/metaplex/types.ts deleted file mode 100644 index 00dd2e7b5..000000000 --- a/explorer/src/metaplex/types.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - Taken from: https://github.com/metaplex-foundation/metaplex/blob/master/js/packages/common/src/actions/metadata.ts -*/ - -export type StringPublicKey = string; - -export const EDITION = "edition"; -export const METADATA_PREFIX = "metadata"; - -export const MAX_AUCTION_DATA_EXTENDED_SIZE = 8 + 9 + 2 + 200; - -export const MAX_NAME_LENGTH = 32; -export const MAX_SYMBOL_LENGTH = 10; -export const MAX_URI_LENGTH = 200; -export const MAX_CREATOR_LIMIT = 5; -export const EDITION_MARKER_BIT_SIZE = 248; -export const MAX_CREATOR_LEN = 32 + 1 + 1; -export const MAX_METADATA_LEN = - 1 + - 32 + - 32 + - MAX_NAME_LENGTH + - MAX_SYMBOL_LENGTH + - MAX_URI_LENGTH + - MAX_CREATOR_LIMIT * MAX_CREATOR_LEN + - 2 + - 1 + - 1 + - 198; - -export enum MetadataKey { - Uninitialized = 0, - MetadataV1 = 4, - EditionV1 = 1, - MasterEditionV1 = 2, - MasterEditionV2 = 6, - EditionMarker = 7, -} - -export enum MetadataCategory { - Audio = "audio", - Video = "video", - Image = "image", - VR = "vr", - HTML = "html", -} - -export type MetadataFile = { - uri: string; - type: string; -}; - -export type FileOrString = MetadataFile | string; - -export interface Auction { - name: string; - auctionerName: string; - auctionerLink: string; - highestBid: number; - solAmt: number; - link: string; - image: string; -} - -export interface Artist { - address?: string; - name: string; - link: string; - image: string; - itemsAvailable?: number; - itemsSold?: number; - about?: string; - verified?: boolean; - - share?: number; -} - -export enum ArtType { - Master, - Print, - NFT, -} -export interface Art { - url: string; -} - -export enum MetaplexKey { - Uninitialized = 0, - OriginalAuthorityLookupV1 = 1, - BidRedemptionTicketV1 = 2, - StoreV1 = 3, - WhitelistedCreatorV1 = 4, - PayoutTicketV1 = 5, - SafetyDepositValidationTicketV1 = 6, - AuctionManagerV1 = 7, - PrizeTrackingTicketV1 = 8, - SafetyDepositConfigV1 = 9, - AuctionManagerV2 = 10, - BidRedemptionTicketV2 = 11, - AuctionWinnerTokenTypeTrackerV1 = 12, -} diff --git a/explorer/src/metaplex/utils.ts b/explorer/src/metaplex/utils.ts deleted file mode 100644 index 76454cad7..000000000 --- a/explorer/src/metaplex/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - Taken from: https://github.com/metaplex-foundation/metaplex/blob/master/js/packages/common/src/utils/utils.ts -*/ - -import { PublicKey } from "@solana/web3.js"; - -export const pubkeyToString = (key: PublicKey | string = "") => { - return typeof key === "string" ? key : key?.toBase58() || ""; -}; - -export const getLast = (arr: T[]) => { - if (arr.length <= 0) { - return undefined; - } - - return arr[arr.length - 1]; -}; diff --git a/explorer/src/providers/accounts/index.tsx b/explorer/src/providers/accounts/index.tsx index de8da26e6..4a8b47ac4 100644 --- a/explorer/src/providers/accounts/index.tsx +++ b/explorer/src/providers/accounts/index.tsx @@ -25,12 +25,8 @@ import { UpgradeableLoaderAccount, } from "validators/accounts/upgradeable-program"; import { RewardsProvider } from "./rewards"; -import { Metadata } from "metaplex/classes"; -import { - EditionData, - getEditionData, - getMetadata, -} from "./utils/metadataHelpers"; +import { Metadata, MetadataData } from "@metaplex/js"; +import getEditionInfo, { EditionInfo } from "./utils/getEditionInfo"; export { useAccountHistory } from "./history"; export type StakeProgramData = { @@ -46,8 +42,8 @@ export type UpgradeableLoaderAccountData = { }; export type NFTData = { - metadata: Metadata; - editionData?: EditionData; + metadata: MetadataData; + editionInfo: EditionInfo; }; export type TokenProgramData = { @@ -243,11 +239,17 @@ async function fetchAccountInfo( // Generate a PDA and check for a Metadata Account if (parsed.type === "mint") { - const metadata = await getMetadata(pubkey, url); + const metadata = await Metadata.load( + connection, + await Metadata.getPDA(pubkey) + ); if (metadata) { // We have a valid Metadata account. Try and pull edition data. - const editionData = await getEditionData(pubkey, url); - nftData = { metadata, editionData }; + const editionInfo = await getEditionInfo( + metadata, + connection + ); + nftData = { metadata: metadata.data, editionInfo }; } } data = { diff --git a/explorer/src/providers/accounts/utils/getEditionInfo.ts b/explorer/src/providers/accounts/utils/getEditionInfo.ts new file mode 100644 index 000000000..c671d3231 --- /dev/null +++ b/explorer/src/providers/accounts/utils/getEditionInfo.ts @@ -0,0 +1,52 @@ +import { + EditionData, + MasterEdition, + MasterEditionData, + Metadata, + MetadataKey, +} from "@metaplex/js"; +import { Connection } from "@solana/web3.js"; + +export type EditionInfo = { + masterEdition?: MasterEditionData; + edition?: EditionData; +}; + +export default async function getEditionInfo( + metadata: Metadata, + connection: Connection +): Promise { + try { + const edition = (await metadata.getEdition(connection)).data; + + if (edition) { + if ( + edition.key === MetadataKey.MasterEditionV1 || + edition.key === MetadataKey.MasterEditionV2 + ) { + return { + masterEdition: edition as MasterEditionData, + edition: undefined, + }; + } + + // This is an Edition NFT. Pull the Parent (MasterEdition) + const masterEdition = ( + await MasterEdition.load(connection, (edition as EditionData).parent) + ).data; + if (masterEdition) { + return { + masterEdition, + edition: edition as EditionData, + }; + } + } + } catch { + /* ignore */ + } + + return { + masterEdition: undefined, + edition: undefined, + }; +} diff --git a/explorer/src/providers/accounts/utils/metadataHelpers.ts b/explorer/src/providers/accounts/utils/metadataHelpers.ts deleted file mode 100644 index e8c22b8c3..000000000 --- a/explorer/src/providers/accounts/utils/metadataHelpers.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { AccountInfo, Connection, PublicKey } from "@solana/web3.js"; -import { - Edition, - MasterEditionV1, - MasterEditionV2, - Metadata, - METADATA_SCHEMA, -} from "metaplex/classes"; -import { MetadataKey, METADATA_PREFIX, StringPublicKey } from "metaplex/types"; -import { deserializeUnchecked, BinaryReader, BinaryWriter } from "borsh"; -import base58 from "bs58"; -import { - METADATA_PROGRAM_ID, - SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, - METAPLEX_ID, - BPF_UPGRADE_LOADER_ID, - SYSTEM, - MEMO_ID, - VAULT_ID, - AUCTION_ID, - toPublicKey, -} from "metaplex/ids"; -import { TOKEN_PROGRAM_ID } from "providers/accounts/tokens"; - -let STORE: PublicKey | undefined; - -export type EditionData = { - masterEdition?: MasterEditionV1 | MasterEditionV2; - edition?: Edition; -}; - -export const programIds = () => { - return { - token: TOKEN_PROGRAM_ID, - associatedToken: SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, - bpf_upgrade_loader: BPF_UPGRADE_LOADER_ID, - system: SYSTEM, - metadata: METADATA_PROGRAM_ID, - memo: MEMO_ID, - vault: VAULT_ID, - auction: AUCTION_ID, - metaplex: METAPLEX_ID, - store: STORE, - }; -}; - -export async function getMetadata( - pubkey: PublicKey, - url: string -): Promise { - const connection = new Connection(url, "confirmed"); - const metadataKey = await generatePDA(pubkey); - const accountInfo = await connection.getAccountInfo(toPublicKey(metadataKey)); - - if (accountInfo && accountInfo.data.length > 0) { - if (!isMetadataAccount(accountInfo)) return; - - if (isMetadataV1Account(accountInfo)) { - const metadata = decodeMetadata(accountInfo.data); - - if (isValidHttpUrl(metadata.data.uri)) { - return metadata; - } - } - } -} - -export async function getEditionData( - pubkey: PublicKey, - url: string -): Promise { - const connection = new Connection(url, "confirmed"); - const editionKey = await generatePDA(pubkey, true /* addEditionToSeeds */); - const accountInfo = await connection.getAccountInfo(toPublicKey(editionKey)); - - if (accountInfo && accountInfo.data.length > 0) { - if (!isMetadataAccount(accountInfo)) return; - - if (isMasterEditionAccount(accountInfo)) { - return { - masterEdition: decodeMasterEdition(accountInfo.data), - edition: undefined, - }; - } - - // This is an Edition NFT. Pull the Parent (MasterEdition) - if (isEditionV1Account(accountInfo)) { - const edition = decodeEdition(accountInfo.data); - const masterEditionAccountInfo = await connection.getAccountInfo( - toPublicKey(edition.parent) - ); - - if ( - masterEditionAccountInfo && - masterEditionAccountInfo.data.length > 0 && - isMasterEditionAccount(masterEditionAccountInfo) - ) { - return { - masterEdition: decodeMasterEdition(masterEditionAccountInfo.data), - edition, - }; - } - } - } - - return; -} - -async function generatePDA( - tokenMint: PublicKey, - addEditionToSeeds: boolean = false -): Promise { - const PROGRAM_IDS = programIds(); - - const metadataSeeds = [ - Buffer.from(METADATA_PREFIX), - toPublicKey(PROGRAM_IDS.metadata).toBuffer(), - tokenMint.toBuffer(), - ]; - - if (addEditionToSeeds) { - metadataSeeds.push(Buffer.from("edition")); - } - - return ( - await PublicKey.findProgramAddress( - metadataSeeds, - toPublicKey(PROGRAM_IDS.metadata) - ) - )[0]; -} - -const decodeMetadata = (buffer: Buffer): Metadata => { - const metadata = deserializeUnchecked( - METADATA_SCHEMA, - Metadata, - buffer - ) as Metadata; - - // Remove any trailing null characters from the deserialized strings - metadata.data.name = metadata.data.name.replace(/\0/g, ""); - metadata.data.symbol = metadata.data.symbol.replace(/\0/g, ""); - metadata.data.uri = metadata.data.uri.replace(/\0/g, ""); - metadata.data.name = metadata.data.name.replace(/\0/g, ""); - return metadata; -}; - -export const decodeMasterEdition = ( - buffer: Buffer -): MasterEditionV1 | MasterEditionV2 => { - if (buffer[0] === MetadataKey.MasterEditionV1) { - return deserializeUnchecked( - METADATA_SCHEMA, - MasterEditionV1, - buffer - ) as MasterEditionV1; - } else { - return deserializeUnchecked( - METADATA_SCHEMA, - MasterEditionV2, - buffer - ) as MasterEditionV2; - } -}; - -export const decodeEdition = (buffer: Buffer) => { - return deserializeUnchecked(METADATA_SCHEMA, Edition, buffer) as Edition; -}; - -const isMetadataAccount = (account: AccountInfo) => - account.owner.toBase58() === METADATA_PROGRAM_ID; - -const isMetadataV1Account = (account: AccountInfo) => - account.data[0] === MetadataKey.MetadataV1; - -const isEditionV1Account = (account: AccountInfo) => - account.data[0] === MetadataKey.EditionV1; - -const isMasterEditionAccount = (account: AccountInfo) => - account.data[0] === MetadataKey.MasterEditionV1 || - account.data[0] === MetadataKey.MasterEditionV2; - -function isValidHttpUrl(text: string) { - try { - const url = new URL(text); - return url.protocol === "http:" || url.protocol === "https:"; - } catch (_) { - return false; - } -} - -// Required to properly serialize and deserialize pubKeyAsString types -const extendBorsh = () => { - (BinaryReader.prototype as any).readPubkey = function () { - const reader = this as unknown as BinaryReader; - const array = reader.readFixedArray(32); - return new PublicKey(array); - }; - - (BinaryWriter.prototype as any).writePubkey = function (value: any) { - const writer = this as unknown as BinaryWriter; - writer.writeFixedArray(value.toBuffer()); - }; - - (BinaryReader.prototype as any).readPubkeyAsString = function () { - const reader = this as unknown as BinaryReader; - const array = reader.readFixedArray(32); - return base58.encode(array) as StringPublicKey; - }; - - (BinaryWriter.prototype as any).writePubkeyAsString = function ( - value: StringPublicKey - ) { - const writer = this as unknown as BinaryWriter; - writer.writeFixedArray(base58.decode(value)); - }; -}; - -extendBorsh(); diff --git a/explorer/src/utils/index.tsx b/explorer/src/utils/index.tsx index 95fa3943c..630c88099 100644 --- a/explorer/src/utils/index.tsx +++ b/explorer/src/utils/index.tsx @@ -1,9 +1,9 @@ -import React from "react"; import BN from "bn.js"; import { HumanizeDuration, HumanizeDurationLanguage, } from "humanize-duration-ts"; +import { PublicKey } from "@solana/web3.js"; // Switch to web3 constant when web3 updates superstruct export const LAMPORTS_PER_SOL = 1000000000; @@ -133,3 +133,15 @@ export function abbreviatedNumber(value: number, fixed = 1) { if (value >= 1e9 && value < 1e12) return +(value / 1e9).toFixed(fixed) + "B"; if (value >= 1e12) return +(value / 1e12).toFixed(fixed) + "T"; } + +export const pubkeyToString = (key: PublicKey | string = "") => { + return typeof key === "string" ? key : key?.toBase58() || ""; +}; + +export const getLast = (arr: string[]) => { + if (arr.length <= 0) { + return undefined; + } + + return arr[arr.length - 1]; +};