Adding NFT support to the Explorer (#20009)
* Adding NFT support to the explorer / copying over required Metaplex logic * Fixing a whitespace issue causing validation to fail * Removed MetadataProvider and instead metadata is being stamped on TokenProgramData * Fixing EOF new line sanity check issue * Added styling improvements to the Creator dropdown and NFT asset * Forgot to run Prettier * Creator address links were only redirecting to Mainnet. This redirects to the appropriate cluster * Removed dependencies not required for Explorer based use. Fixed package-lock.json because of a legacy npm version * Removed react-content-loader and popperjs * Removed MeshArt. Nobody likes VR anyways * Capped HTML animation asset width to 150px * Added an Editon check to properly identify NFTs * Refactoring away for un-necessary helpers * Dropped antd and added an image loading placeholder * Added a HTML animation flickering fix * Removed arweave check for valid uri properties * Resolving some nit comments and cleaning up * Adding Tooltips to better explain the content in the NFT Header * Started consuming MasterEdition data which is being used to display Seller Fee and Max Supply information in the Token Account Section * Fixing a bug where Edition NFTs weren't properly supported * Added better Edition support and labeling when there isn't Master Edition information added to metaplex metadata * Fixed Max Supply issue where 0 should be displayed as 1 * Updated tooltips to be shorter and more user friendly * Separting NFTHeader from AccountDetailsPage, adding a new TokenSection for NFTs and adding some cleanup
This commit is contained in:
1722
explorer/package-lock.json
generated
1722
explorer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blockworks-foundation/mango-client": "^3.0.24",
|
"@blockworks-foundation/mango-client": "^3.0.24",
|
||||||
"@bonfida/bot": "^0.5.3",
|
"@bonfida/bot": "^0.5.3",
|
||||||
|
"@cloudflare/stream-react": "^1.2.0",
|
||||||
"@metamask/jazzicon": "^2.0.0",
|
"@metamask/jazzicon": "^2.0.0",
|
||||||
"@project-serum/serum": "^0.13.60",
|
"@project-serum/serum": "^0.13.60",
|
||||||
"@react-hook/debounce": "^4.0.0",
|
"@react-hook/debounce": "^4.0.0",
|
||||||
@ -39,6 +40,7 @@
|
|||||||
"prettier": "^2.4.1",
|
"prettier": "^2.4.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-chartjs-2": "^2.11.2",
|
"react-chartjs-2": "^2.11.2",
|
||||||
|
"react-content-loader": "^6.0.3",
|
||||||
"react-countup": "^6.0.0",
|
"react-countup": "^6.0.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-moment": "^1.1.1",
|
"react-moment": "^1.1.1",
|
||||||
|
171
explorer/src/components/account/MetaplexNFTHeader.tsx
Normal file
171
explorer/src/components/account/MetaplexNFTHeader.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import "bootstrap/dist/js/bootstrap.min.js";
|
||||||
|
import { NFTData } from "providers/accounts";
|
||||||
|
import { Creator } from "metaplex/classes";
|
||||||
|
import { ArtContent } from "metaplex/Art/Art";
|
||||||
|
import { InfoTooltip } from "components/common/InfoTooltip";
|
||||||
|
import { EditionData } from "providers/accounts/utils/metadataHelpers";
|
||||||
|
import { clusterPath } from "utils/url";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function NFTHeader({
|
||||||
|
nftData,
|
||||||
|
address,
|
||||||
|
}: {
|
||||||
|
nftData: NFTData;
|
||||||
|
address: string;
|
||||||
|
}) {
|
||||||
|
const metadata = nftData.metadata;
|
||||||
|
return (
|
||||||
|
<div className="row align-items-begin">
|
||||||
|
<div className="col-auto ml-2">
|
||||||
|
<ArtContent metadata={metadata} pubkey={address} preview={false} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col mb-3 ml-n3 ml-md-n2 mt-3">
|
||||||
|
{<h6 className="header-pretitle ml-1">Metaplex NFT</h6>}
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<h2 className="header-title ml-1 align-items-center">
|
||||||
|
{metadata.data.name !== ""
|
||||||
|
? metadata.data.name
|
||||||
|
: "No NFT name was found"}
|
||||||
|
</h2>
|
||||||
|
{getEditionPill(nftData.editionData)}
|
||||||
|
</div>
|
||||||
|
<h4 className="header-pretitle ml-1 mt-1">
|
||||||
|
{metadata.data.symbol !== ""
|
||||||
|
? metadata.data.symbol
|
||||||
|
: "No Symbol was found"}
|
||||||
|
</h4>
|
||||||
|
<div className="mb-2 mt-2">
|
||||||
|
{getSaleTypePill(metadata.primarySaleHappened)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 mt-2">{getIsMutablePill(metadata.isMutable)}</div>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
className="btn btn-dark btn-sm dropdown-toggle creators-dropdown-button-width"
|
||||||
|
type="button"
|
||||||
|
data-toggle="dropdown"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Creators
|
||||||
|
</button>
|
||||||
|
<div className="dropdown-menu mt-2">
|
||||||
|
{getCreatorDropdownItems(metadata.data.creators)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreatorDropdownItems(creators: Creator[] | null) {
|
||||||
|
const CreatorHeader = () => {
|
||||||
|
const creatorTooltip =
|
||||||
|
"Verified creators signed the metadata associated with this NFT when it was created.";
|
||||||
|
|
||||||
|
const shareTooltip =
|
||||||
|
"The percentage of the proceeds a creator receives when this NFT is sold.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"d-flex align-items-center dropdown-header creator-dropdown-entry"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="d-flex text-monospace creator-dropdown-header">
|
||||||
|
<span>Creator Address</span>
|
||||||
|
<InfoTooltip bottom text={creatorTooltip} />
|
||||||
|
</div>
|
||||||
|
<div className="d-flex text-monospace">
|
||||||
|
<span className="text-monospace">Royalty</span>
|
||||||
|
<InfoTooltip bottom text={shareTooltip} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVerifiedIcon = (isVerified: boolean) => {
|
||||||
|
const className = isVerified ? "fe fe-check" : "fe fe-alert-octagon";
|
||||||
|
return <i className={`ml-3 ${className}`}></i>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreatorEntry = (creator: Creator) => {
|
||||||
|
return (
|
||||||
|
<div className={"d-flex align-items-center creator-dropdown-entry"}>
|
||||||
|
{getVerifiedIcon(creator.verified)}
|
||||||
|
<Link
|
||||||
|
className="dropdown-item text-monospace creator-dropdown-entry-address"
|
||||||
|
to={clusterPath(`/address/${creator.address}`)}
|
||||||
|
>
|
||||||
|
{creator.address}
|
||||||
|
</Link>
|
||||||
|
<div className="mr-3"> {`${creator.share}%`}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (creators && creators.length > 0) {
|
||||||
|
let listOfCreators: JSX.Element[] = [];
|
||||||
|
|
||||||
|
listOfCreators.push(<CreatorHeader key={"header"} />);
|
||||||
|
creators.forEach((creator) => {
|
||||||
|
listOfCreators.push(<CreatorEntry key={creator.address} {...creator} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
return listOfCreators;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"dropdown-item text-monospace"}>
|
||||||
|
<div className="mr-3">No creators are associated with this NFT.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditionPill(editionData?: EditionData) {
|
||||||
|
const masterEdition = editionData?.masterEdition;
|
||||||
|
const edition = editionData?.edition;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"d-inline-flex ml-2"}>
|
||||||
|
<span className="badge badge-pill badge-dark">{`${
|
||||||
|
edition && masterEdition
|
||||||
|
? `Edition ${edition.edition.toNumber()} / ${masterEdition.supply.toNumber()}`
|
||||||
|
: masterEdition
|
||||||
|
? "Master Edition"
|
||||||
|
: "No Master Edition Information"
|
||||||
|
}`}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSaleTypePill(hasPrimarySaleHappened: boolean) {
|
||||||
|
const primaryMarketTooltip =
|
||||||
|
"Creator(s) split 100% of the proceeds when this NFT is sold.";
|
||||||
|
|
||||||
|
const secondaryMarketTooltip =
|
||||||
|
"Creator(s) split the Seller Fee when this NFT is sold. The owner receives the remaining proceeds.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"d-inline-flex align-items-center"}>
|
||||||
|
<span className="badge badge-pill badge-dark">{`${
|
||||||
|
hasPrimarySaleHappened ? "Secondary Market" : "Primary Market"
|
||||||
|
}`}</span>
|
||||||
|
<InfoTooltip
|
||||||
|
bottom
|
||||||
|
text={
|
||||||
|
hasPrimarySaleHappened ? secondaryMarketTooltip : primaryMarketTooltip
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIsMutablePill(isMutable: boolean) {
|
||||||
|
return (
|
||||||
|
<span className="badge badge-pill badge-dark">{`${
|
||||||
|
isMutable ? "Mutable" : "Immutable"
|
||||||
|
}`}</span>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import { Account, NFTData, useFetchAccountInfo } from "providers/accounts";
|
||||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
|
||||||
import {
|
import {
|
||||||
TokenAccount,
|
TokenAccount,
|
||||||
MintAccountInfo,
|
MintAccountInfo,
|
||||||
@ -20,6 +19,7 @@ import { Copyable } from "components/common/Copyable";
|
|||||||
import { CoingeckoStatus, useCoinGecko } from "utils/coingecko";
|
import { CoingeckoStatus, useCoinGecko } from "utils/coingecko";
|
||||||
import { displayTimestampWithoutDate } from "utils/date";
|
import { displayTimestampWithoutDate } from "utils/date";
|
||||||
import { LoadingCard } from "components/common/LoadingCard";
|
import { LoadingCard } from "components/common/LoadingCard";
|
||||||
|
import { toPublicKey } from "metaplex/ids";
|
||||||
|
|
||||||
const getEthAddress = (link?: string) => {
|
const getEthAddress = (link?: string) => {
|
||||||
let address = "";
|
let address = "";
|
||||||
@ -47,7 +47,21 @@ export function TokenAccountSection({
|
|||||||
switch (tokenAccount.type) {
|
switch (tokenAccount.type) {
|
||||||
case "mint": {
|
case "mint": {
|
||||||
const info = create(tokenAccount.info, MintAccountInfo);
|
const info = create(tokenAccount.info, MintAccountInfo);
|
||||||
return <MintAccountCard account={account} info={info} />;
|
|
||||||
|
if (
|
||||||
|
account.details?.data?.program === "spl-token" &&
|
||||||
|
account.details.data.nftData
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<NonFungibleTokenMintAccountCard
|
||||||
|
account={account}
|
||||||
|
nftData={account.details.data.nftData}
|
||||||
|
mintInfo={info}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FungibleTokenMintAccountCard account={account} info={info} />;
|
||||||
}
|
}
|
||||||
case "account": {
|
case "account": {
|
||||||
const info = create(tokenAccount.info, TokenAccountInfo);
|
const info = create(tokenAccount.info, TokenAccountInfo);
|
||||||
@ -68,7 +82,7 @@ export function TokenAccountSection({
|
|||||||
return <UnknownAccountCard account={account} />;
|
return <UnknownAccountCard account={account} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MintAccountCard({
|
function FungibleTokenMintAccountCard({
|
||||||
account,
|
account,
|
||||||
info,
|
info,
|
||||||
}: {
|
}: {
|
||||||
@ -270,6 +284,87 @@ function MintAccountCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NonFungibleTokenMintAccountCard({
|
||||||
|
account,
|
||||||
|
nftData,
|
||||||
|
mintInfo,
|
||||||
|
}: {
|
||||||
|
account: Account;
|
||||||
|
nftData: NFTData;
|
||||||
|
mintInfo: MintAccountInfo;
|
||||||
|
}) {
|
||||||
|
const fetchInfo = useFetchAccountInfo();
|
||||||
|
const refresh = () => fetchInfo(account.pubkey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||||
|
Overview
|
||||||
|
</h3>
|
||||||
|
<button className="btn btn-white btn-sm" onClick={refresh}>
|
||||||
|
<span className="fe fe-refresh-cw mr-2"></span>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TableCardBody>
|
||||||
|
<tr>
|
||||||
|
<td>Address</td>
|
||||||
|
<td className="text-lg-right">
|
||||||
|
<Address pubkey={account.pubkey} alignRight raw />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{nftData?.editionData?.masterEdition?.maxSupply && (
|
||||||
|
<tr>
|
||||||
|
<td>Max Total Supply</td>
|
||||||
|
<td className="text-lg-right">
|
||||||
|
{nftData.editionData.masterEdition.maxSupply.toNumber() === 0
|
||||||
|
? 1
|
||||||
|
: nftData.editionData.masterEdition.maxSupply.toNumber()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{nftData?.editionData?.masterEdition?.supply && (
|
||||||
|
<tr>
|
||||||
|
<td>Current Supply</td>
|
||||||
|
<td className="text-lg-right">
|
||||||
|
{nftData.editionData.masterEdition.supply.toNumber() === 0
|
||||||
|
? 1
|
||||||
|
: nftData.editionData.masterEdition.supply.toNumber()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{mintInfo.mintAuthority && (
|
||||||
|
<tr>
|
||||||
|
<td>Mint Authority</td>
|
||||||
|
<td className="text-lg-right">
|
||||||
|
<Address pubkey={mintInfo.mintAuthority} alignRight link />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr>
|
||||||
|
<td>Update Authority</td>
|
||||||
|
<td className="text-lg-right">
|
||||||
|
<Address
|
||||||
|
pubkey={toPublicKey(nftData.metadata.updateAuthority)}
|
||||||
|
alignRight
|
||||||
|
link
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{nftData?.metadata.data && (
|
||||||
|
<tr>
|
||||||
|
<td>Seller Fee</td>
|
||||||
|
<td className="text-lg-right">
|
||||||
|
{`${nftData?.metadata.data.sellerFeeBasisPoints / 100}%`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</TableCardBody>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TokenAccountCard({
|
function TokenAccountCard({
|
||||||
account,
|
account,
|
||||||
info,
|
info,
|
||||||
|
@ -2,7 +2,7 @@ import React, { useState, ReactNode } from "react";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
bottom?: boolean;
|
bottom?: boolean;
|
||||||
right?: boolean;
|
right?: boolean;
|
||||||
};
|
};
|
||||||
|
268
explorer/src/metaplex/Art/Art.tsx
Normal file
268
explorer/src/metaplex/Art/Art.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import React, { Ref, 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 ContentLoader from "react-content-loader";
|
||||||
|
|
||||||
|
const Placeholder = () => (
|
||||||
|
<ContentLoader
|
||||||
|
viewBox="0 0 212 200"
|
||||||
|
height={150}
|
||||||
|
width={150}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
>
|
||||||
|
<circle cx="86" cy="100" r="8" />
|
||||||
|
<circle cx="106" cy="100" r="8" />
|
||||||
|
<circle cx="126" cy="100" r="8" />
|
||||||
|
</ContentLoader>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CachedImageContent = ({
|
||||||
|
uri,
|
||||||
|
}: {
|
||||||
|
uri?: string;
|
||||||
|
className?: string;
|
||||||
|
preview?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => {
|
||||||
|
const [loaded, setLoaded] = useState<boolean>(false);
|
||||||
|
const { cachedBlob } = useCachedImage(uri || "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!loaded && <Placeholder />}
|
||||||
|
<img
|
||||||
|
className={`rounded mx-auto ${loaded ? "d-block" : "d-none"}`}
|
||||||
|
src={cachedBlob}
|
||||||
|
loading="lazy"
|
||||||
|
alt={"nft"}
|
||||||
|
style={{
|
||||||
|
width: 150,
|
||||||
|
height: "auto",
|
||||||
|
}}
|
||||||
|
onLoad={() => {
|
||||||
|
setLoaded(true);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
setLoaded(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoArtContent = ({
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
files,
|
||||||
|
uri,
|
||||||
|
animationURL,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
files?: (MetadataFile | string)[];
|
||||||
|
uri?: string;
|
||||||
|
animationURL?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}) => {
|
||||||
|
const [playerApi, setPlayerApi] = useState<StreamPlayerApi>();
|
||||||
|
|
||||||
|
const playerRef = useCallback(
|
||||||
|
(ref) => {
|
||||||
|
setPlayerApi(ref);
|
||||||
|
},
|
||||||
|
[setPlayerApi]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playerApi) {
|
||||||
|
playerApi.currentTime = 0;
|
||||||
|
}
|
||||||
|
}, [active, playerApi]);
|
||||||
|
|
||||||
|
const likelyVideo = (files || []).filter((f, index, arr) => {
|
||||||
|
if (typeof f !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: filter by fileType
|
||||||
|
return arr.length >= 2 ? index === 1 : index === 0;
|
||||||
|
})?.[0] as string;
|
||||||
|
|
||||||
|
const content =
|
||||||
|
likelyVideo &&
|
||||||
|
likelyVideo.startsWith("https://watch.videodelivery.net/") ? (
|
||||||
|
<div className={`${className} square`}>
|
||||||
|
<Stream
|
||||||
|
streamRef={(e: any) => playerRef(e)}
|
||||||
|
src={likelyVideo.replace("https://watch.videodelivery.net/", "")}
|
||||||
|
loop={true}
|
||||||
|
height={150}
|
||||||
|
width={150}
|
||||||
|
controls={false}
|
||||||
|
style={{ borderRadius: 12 }}
|
||||||
|
videoDimensions={{
|
||||||
|
videoHeight: 150,
|
||||||
|
videoWidth: 150,
|
||||||
|
}}
|
||||||
|
autoplay={true}
|
||||||
|
muted={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
className={className}
|
||||||
|
playsInline={true}
|
||||||
|
autoPlay={true}
|
||||||
|
muted={true}
|
||||||
|
controls={true}
|
||||||
|
controlsList="nodownload"
|
||||||
|
style={{ borderRadius: 12, ...style }}
|
||||||
|
loop={true}
|
||||||
|
poster={uri}
|
||||||
|
>
|
||||||
|
{likelyVideo && (
|
||||||
|
<source src={likelyVideo} type="video/mp4" style={style} />
|
||||||
|
)}
|
||||||
|
{animationURL && (
|
||||||
|
<source src={animationURL} type="video/mp4" style={style} />
|
||||||
|
)}
|
||||||
|
{files
|
||||||
|
?.filter((f) => typeof f !== "string")
|
||||||
|
.map((f: any) => (
|
||||||
|
<source src={f.uri} type={f.type} style={style} />
|
||||||
|
))}
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HTMLContent = ({
|
||||||
|
animationUrl,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
files,
|
||||||
|
}: {
|
||||||
|
animationUrl?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
files?: (MetadataFile | string)[];
|
||||||
|
}) => {
|
||||||
|
const [loaded, setLoaded] = useState<boolean>(false);
|
||||||
|
const htmlURL =
|
||||||
|
files && files.length > 0 && typeof files[0] === "string"
|
||||||
|
? files[0]
|
||||||
|
: animationUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!loaded && <Placeholder />}
|
||||||
|
<iframe
|
||||||
|
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
title={"html-content"}
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
frameBorder="0"
|
||||||
|
src={htmlURL}
|
||||||
|
className={`${className} ${loaded ? "d-block" : "d-none"}`}
|
||||||
|
style={{ width: 150, borderRadius: 12, ...style }}
|
||||||
|
onLoad={() => {
|
||||||
|
setLoaded(true);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
setLoaded(true);
|
||||||
|
}}
|
||||||
|
></iframe>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArtContent = ({
|
||||||
|
metadata,
|
||||||
|
category,
|
||||||
|
className,
|
||||||
|
preview,
|
||||||
|
style,
|
||||||
|
active,
|
||||||
|
pubkey,
|
||||||
|
uri,
|
||||||
|
animationURL,
|
||||||
|
files,
|
||||||
|
}: {
|
||||||
|
metadata: Metadata;
|
||||||
|
category?: MetadataCategory;
|
||||||
|
className?: string;
|
||||||
|
preview?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
active?: boolean;
|
||||||
|
pubkey?: PublicKey | string;
|
||||||
|
uri?: string;
|
||||||
|
animationURL?: string;
|
||||||
|
files?: (MetadataFile | string)[];
|
||||||
|
}) => {
|
||||||
|
const id = pubkeyToString(pubkey);
|
||||||
|
|
||||||
|
const { data } = useExtendedArt(id, metadata);
|
||||||
|
|
||||||
|
if (pubkey && data) {
|
||||||
|
uri = data.image;
|
||||||
|
animationURL = data.animation_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pubkey && data?.properties) {
|
||||||
|
files = data.properties.files;
|
||||||
|
category = data.properties.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationURL = animationURL || "";
|
||||||
|
|
||||||
|
const animationUrlExt = new URLSearchParams(
|
||||||
|
getLast(animationURL.split("?"))
|
||||||
|
).get("ext");
|
||||||
|
|
||||||
|
const content =
|
||||||
|
category === "video" ? (
|
||||||
|
<VideoArtContent
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
files={files}
|
||||||
|
uri={uri}
|
||||||
|
animationURL={animationURL}
|
||||||
|
active={active}
|
||||||
|
/>
|
||||||
|
) : category === "html" || animationUrlExt === "html" ? (
|
||||||
|
<HTMLContent
|
||||||
|
animationUrl={animationURL}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
files={files}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CachedImageContent
|
||||||
|
uri={uri}
|
||||||
|
className={className}
|
||||||
|
preview={preview}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
114
explorer/src/metaplex/Art/useArt.ts
Normal file
114
explorer/src/metaplex/Art/useArt.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { IMetadataExtension, Metadata } from "metaplex/classes";
|
||||||
|
import { StringPublicKey } from "metaplex/types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const cachedImages = new Map<string, string>();
|
||||||
|
export const useCachedImage = (uri: string) => {
|
||||||
|
const [cachedBlob, setCachedBlob] = useState<string | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = cachedImages.get(uri);
|
||||||
|
if (result) {
|
||||||
|
setCachedBlob(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading) {
|
||||||
|
(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(uri, { cache: "force-cache" });
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
response = await fetch(uri, { cache: "reload" });
|
||||||
|
} catch {
|
||||||
|
// If external URL, just use the uri
|
||||||
|
if (uri?.startsWith("http")) {
|
||||||
|
setCachedBlob(uri);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobURI = URL.createObjectURL(blob);
|
||||||
|
cachedImages.set(uri, blobURI);
|
||||||
|
setCachedBlob(blobURI);
|
||||||
|
setIsLoading(false);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [uri, setCachedBlob, isLoading, setIsLoading]);
|
||||||
|
|
||||||
|
return { cachedBlob, isLoading };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useExtendedArt = (id: StringPublicKey, metadata: Metadata) => {
|
||||||
|
const [data, setData] = useState<IMetadataExtension>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id && !data) {
|
||||||
|
const USE_CDN = false;
|
||||||
|
const routeCDN = (uri: string) => {
|
||||||
|
let result = uri;
|
||||||
|
if (USE_CDN) {
|
||||||
|
result = uri.replace(
|
||||||
|
"https://arweave.net/",
|
||||||
|
"https://coldcdn.com/api/cdn/bronil/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (metadata.data.uri) {
|
||||||
|
const uri = routeCDN(metadata.data.uri);
|
||||||
|
|
||||||
|
const processJson = (extended: any) => {
|
||||||
|
if (!extended || extended?.properties?.files?.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extended?.image) {
|
||||||
|
const file = extended.image.startsWith("http")
|
||||||
|
? extended.image
|
||||||
|
: `${metadata.data.uri}/${extended.image}`;
|
||||||
|
extended.image = routeCDN(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
};
|
296
explorer/src/metaplex/classes.ts
Normal file
296
explorer/src/metaplex/classes.ts
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
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<any, any>([
|
||||||
|
[
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
72
explorer/src/metaplex/ids.ts
Normal file
72
explorer/src/metaplex/ids.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
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<T> {
|
||||||
|
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<string, PublicKey>();
|
||||||
|
|
||||||
|
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<T> {
|
||||||
|
pubkey: string;
|
||||||
|
account: AccountInfo<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
101
explorer/src/metaplex/types.ts
Normal file
101
explorer/src/metaplex/types.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
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,
|
||||||
|
}
|
17
explorer/src/metaplex/utils.ts
Normal file
17
explorer/src/metaplex/utils.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
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 = <T>(arr: T[]) => {
|
||||||
|
if (arr.length <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr[arr.length - 1];
|
||||||
|
};
|
@ -34,6 +34,7 @@ import { TransactionHistoryCard } from "components/account/history/TransactionHi
|
|||||||
import { TokenTransfersCard } from "components/account/history/TokenTransfersCard";
|
import { TokenTransfersCard } from "components/account/history/TokenTransfersCard";
|
||||||
import { TokenInstructionsCard } from "components/account/history/TokenInstructionsCard";
|
import { TokenInstructionsCard } from "components/account/history/TokenInstructionsCard";
|
||||||
import { RewardsCard } from "components/account/RewardsCard";
|
import { RewardsCard } from "components/account/RewardsCard";
|
||||||
|
import { NFTHeader } from "components/account/MetaplexNFTHeader";
|
||||||
|
|
||||||
const IDENTICON_WIDTH = 64;
|
const IDENTICON_WIDTH = 64;
|
||||||
|
|
||||||
@ -151,6 +152,11 @@ export function AccountHeader({
|
|||||||
const account = info?.data;
|
const account = info?.data;
|
||||||
const data = account?.details?.data;
|
const data = account?.details?.data;
|
||||||
const isToken = data?.program === "spl-token" && data?.parsed.type === "mint";
|
const isToken = data?.program === "spl-token" && data?.parsed.type === "mint";
|
||||||
|
const isNFT = isToken && data.nftData;
|
||||||
|
|
||||||
|
if (isNFT) {
|
||||||
|
return <NFTHeader nftData={data.nftData!} address={address} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (tokenDetails || isToken) {
|
if (tokenDetails || isToken) {
|
||||||
return (
|
return (
|
||||||
|
@ -25,6 +25,12 @@ import {
|
|||||||
UpgradeableLoaderAccount,
|
UpgradeableLoaderAccount,
|
||||||
} from "validators/accounts/upgradeable-program";
|
} from "validators/accounts/upgradeable-program";
|
||||||
import { RewardsProvider } from "./rewards";
|
import { RewardsProvider } from "./rewards";
|
||||||
|
import { Metadata } from "metaplex/classes";
|
||||||
|
import {
|
||||||
|
EditionData,
|
||||||
|
getEditionData,
|
||||||
|
getMetadata,
|
||||||
|
} from "./utils/metadataHelpers";
|
||||||
export { useAccountHistory } from "./history";
|
export { useAccountHistory } from "./history";
|
||||||
|
|
||||||
export type StakeProgramData = {
|
export type StakeProgramData = {
|
||||||
@ -39,9 +45,15 @@ export type UpgradeableLoaderAccountData = {
|
|||||||
programData?: ProgramDataAccountInfo;
|
programData?: ProgramDataAccountInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NFTData = {
|
||||||
|
metadata: Metadata;
|
||||||
|
editionData?: EditionData;
|
||||||
|
};
|
||||||
|
|
||||||
export type TokenProgramData = {
|
export type TokenProgramData = {
|
||||||
program: "spl-token";
|
program: "spl-token";
|
||||||
parsed: TokenAccount;
|
parsed: TokenAccount;
|
||||||
|
nftData?: NFTData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VoteProgramData = {
|
export type VoteProgramData = {
|
||||||
@ -226,9 +238,22 @@ async function fetchAccountInfo(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "spl-token":
|
case "spl-token":
|
||||||
|
const parsed = create(info, TokenAccount);
|
||||||
|
let nftData;
|
||||||
|
|
||||||
|
// Generate a PDA and check for a Metadata Account
|
||||||
|
if (parsed.type === "mint") {
|
||||||
|
const metadata = await getMetadata(pubkey, url);
|
||||||
|
if (metadata) {
|
||||||
|
// We have a valid Metadata account. Try and pull edition data.
|
||||||
|
const editionData = await getEditionData(pubkey, url);
|
||||||
|
nftData = { metadata, editionData };
|
||||||
|
}
|
||||||
|
}
|
||||||
data = {
|
data = {
|
||||||
program: result.data.program,
|
program: result.data.program,
|
||||||
parsed: create(info, TokenAccount),
|
parsed,
|
||||||
|
nftData,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
219
explorer/src/providers/accounts/utils/metadataHelpers.ts
Normal file
219
explorer/src/providers/accounts/utils/metadataHelpers.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
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<Metadata | undefined> {
|
||||||
|
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<EditionData | undefined> {
|
||||||
|
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<PublicKey> {
|
||||||
|
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<Buffer>) =>
|
||||||
|
account.owner.toBase58() === METADATA_PROGRAM_ID;
|
||||||
|
|
||||||
|
const isMetadataV1Account = (account: AccountInfo<Buffer>) =>
|
||||||
|
account.data[0] === MetadataKey.MetadataV1;
|
||||||
|
|
||||||
|
const isEditionV1Account = (account: AccountInfo<Buffer>) =>
|
||||||
|
account.data[0] === MetadataKey.EditionV1;
|
||||||
|
|
||||||
|
const isMasterEditionAccount = (account: AccountInfo<Buffer>) =>
|
||||||
|
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();
|
@ -381,7 +381,7 @@ pre.json-wrap {
|
|||||||
p.updated-time {
|
p.updated-time {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
margin: .375rem;
|
margin: 0.375rem;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
@ -399,5 +399,24 @@ p.updated-time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.identicon-wrapper-small {
|
.identicon-wrapper-small {
|
||||||
margin-left: .4rem;
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creators-dropdown-button-width {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-dropdown-entry {
|
||||||
|
flex-flow: row wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-dropdown-header {
|
||||||
|
max-width: 80%;
|
||||||
|
flex: 0 0 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-dropdown-entry-address {
|
||||||
|
@extend .creator-dropdown-header;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user