Added code to retrieve Metaplex metadata for NFTs

This commit is contained in:
Yandre 2021-10-14 14:53:14 -04:00
parent c12d8ea20d
commit 209b2c47df
8 changed files with 25894 additions and 10 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import React, { useCallback, useState } from 'react'
import useMangoStore from '../stores/useMangoStore'
import { Menu } from '@headlessui/react'
import {
@ -15,19 +15,20 @@ import {
import useLocalStorageState from '../hooks/useLocalStorageState'
import { abbreviateAddress, copyToClipboard } from '../utils'
import WalletSelect from './WalletSelect'
import { WalletIcon } from './icons'
import { ProfileIcon, WalletIcon } from './icons'
import AccountsModal from './AccountsModal'
import { useEffect } from 'react'
import SettingsModal from './SettingsModal'
import mango_hero from '../components/assets/mango_heroes.jpg'
const ConnectWalletButton = () => {
const wallet = useMangoStore((s) => s.wallet.current)
const connected = useMangoStore((s) => s.wallet.connected)
const nfts = useMangoStore((s) => s.settings.nfts)
const set = useMangoStore((s) => s.set)
const [showAccountsModal, setShowAccountsModal] = useState(false)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [selectedWallet, setSelectedWallet] = useState(DEFAULT_PROVIDER.url)
const [imageUrl, setImageUrl] = useState('')
const [savedProviderUrl] = useLocalStorageState(
PROVIDER_LOCAL_STORAGE_KEY,
DEFAULT_PROVIDER.url
@ -38,6 +39,24 @@ const ConnectWalletButton = () => {
setSelectedWallet(savedProviderUrl)
}, [savedProviderUrl])
useEffect(() => {
if (nfts.length == 0) return
const nft = nfts[0]
try {
fetch(nft).then(async (_) => {
try {
const data = await _.json()
setImageUrl(data['image'] as string)
} catch (ex) {
console.error('Error trying to parse JSON: ' + ex)
}
})
} catch (ex) {
console.error('Error trying to fetch Arweave metadata: ' + ex)
}
})
const handleWalletConect = () => {
wallet.connect()
set((state) => {
@ -56,12 +75,16 @@ const ConnectWalletButton = () => {
<div className="relative">
<Menu.Button
className="bg-th-bkg-4 flex items-center justify-center rounded-full w-10 h-10 text-white focus:outline-none hover:bg-th-bkg-4 hover:text-th-fgd-3"
style={{
backgroundImage: `url(${mango_hero.src})`,
backgroundSize: 'cover',
}}
style={
imageUrl != ''
? {
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
}
: null
}
>
{/* <ProfileIcon className="h-6 w-6" /> */}
{imageUrl == '' ? <ProfileIcon className="h-6 w-6" /> : null}
{/* <NewProfileIcon className="h-6 w-6" src={mango_hero.src} /> */}
</Menu.Button>
<Menu.Items className="bg-th-bkg-1 mt-2 p-1 absolute right-0 shadow-lg outline-none rounded-md w-48 z-20">

View File

@ -107,6 +107,7 @@ export default function useWallet() {
actions.reloadOrders()
actions.fetchTradeHistory()
actions.fetchWalletTokens()
actions.fetchProfilePicture()
notify({
title: 'Wallet connected',
description:
@ -144,6 +145,7 @@ export default function useWallet() {
useInterval(() => {
if (connected && mangoAccount) {
actions.fetchWalletTokens()
actions.fetchProfilePicture()
actions.fetchTradeHistory()
}
}, 90 * SECONDS)

25337
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,7 @@
"babel-plugin-import": "^1.13.3",
"big.js": "^6.1.1",
"bn.js": "^5.2.0",
"borsh": "^0.6.0",
"bs58": "^4.0.1",
"buffer-layout": "^1.2.0",
"dayjs": "^1.10.4",

View File

@ -31,6 +31,11 @@ import {
initialMarket,
NODE_URL_KEY,
} from '../components/SettingsModal'
import { TOKEN_PROGRAM_ID } from '../utils/tokens'
import { findProgramAddress } from '../utils/metaplex/utils'
import * as borsh from 'borsh'
import { Metadata, METADATA_SCHEMA } from '../utils/metaplex/models'
import { METADATA_PREFIX } from '../utils/metaplex/types'
export const ENDPOINTS: EndpointInfo[] = [
{
@ -43,8 +48,8 @@ export const ENDPOINTS: EndpointInfo[] = [
name: 'devnet',
// url: 'https://mango.devnet.rpcpool.com',
// websocket: 'https://mango.devnet.rpcpool.com',
url: 'https://api.devnet.solana.com',
websocket: 'https://api.devnet.solana.com',
url: 'https://mango.rpcpool.com',
websocket: 'https://mango.rpcpool.com',
custom: false,
},
]
@ -166,6 +171,7 @@ interface MangoStore extends State {
}
settings: {
uiLocked: boolean
nfts: string[]
}
tradeHistory: any[]
set: (x: any) => void
@ -180,6 +186,8 @@ const useMangoStore = create<MangoStore>((set, get) => {
? JSON.parse(localStorage.getItem(NODE_URL_KEY)) || ENDPOINT.url
: ENDPOINT.url
console.log('RPC url: ', rpcUrl)
const defaultMarket =
typeof window !== 'undefined'
? JSON.parse(localStorage.getItem(DEFAULT_MARKET_KEY)) || initialMarket
@ -237,6 +245,7 @@ const useMangoStore = create<MangoStore>((set, get) => {
wallet: INITIAL_STATE.WALLET,
settings: {
uiLocked: true,
nfts: [],
},
tradeHistory: [],
set: (fn) => set(produce(fn)),
@ -272,6 +281,64 @@ const useMangoStore = create<MangoStore>((set, get) => {
})
}
},
async fetchProfilePicture() {
const wallet = get().wallet.current
const connected = get().wallet.connected
const connection = get().connection.current
const set = get().set
if (wallet?.publicKey && connected) {
const tokenAccounts = await connection.getParsedTokenAccountsByOwner(
wallet.publicKey,
{ programId: TOKEN_PROGRAM_ID }
)
const nftPublicKeys = []
for (const token of tokenAccounts.value) {
const tokenAccount = token.account.data.parsed.info
if (
parseFloat(tokenAccount.tokenAmount.amount) == 1 &&
tokenAccount.tokenAmount.decimals == 0
) {
nftPublicKeys.push(new PublicKey(tokenAccount.mint))
}
}
if (nftPublicKeys.length == 0) return
const metadataProgramId = new PublicKey(METADATA_SCHEMA)
const uris = []
for (const nft of nftPublicKeys) {
const pda = await findProgramAddress(
[
Buffer.from(METADATA_PREFIX),
metadataProgramId.toBuffer(),
nft.toBuffer(),
],
metadataProgramId
)[0]
const accountInfo = await connection.getAccountInfo(
pda,
'processed'
)
const metadata = borsh.deserializeUnchecked(
METADATA_SCHEMA,
Metadata,
accountInfo!.data
)
const uri = metadata.data.uri.replace(/\0/g, '')
uris.push(uri)
}
set((state) => {
state.settings.nfts = uris
})
}
},
async fetchAllMangoAccounts() {
const set = get().set
const mangoGroup = get().selectedMangoGroup.current

328
utils/metaplex/models.tsx Normal file
View File

@ -0,0 +1,328 @@
/*
Taken from: https://github.com/metaplex-foundation/metaplex/blob/master/js/packages/common/src/actions/metadata.ts
*/
import { PublicKey } from '@solana/web3.js'
import BN from 'bn.js'
import { BinaryReader, BinaryWriter } from 'borsh'
import {
StringPublicKey,
EDITION_MARKER_BIT_SIZE,
MetadataKey,
FileOrString,
MetadataCategory,
MetaplexKey,
} from './types'
import base58 from 'bs58'
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.WhitelistedCreatorV1
address: StringPublicKey
activated = 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
}
}
// 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()

102
utils/metaplex/types.tsx Normal file
View File

@ -0,0 +1,102 @@
/*
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 METADATA_KEY = 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'
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,
}

24
utils/metaplex/utils.tsx Normal file
View File

@ -0,0 +1,24 @@
import { PublicKey } from '@solana/web3.js'
export const findProgramAddress = async (
seeds: (Buffer | Uint8Array)[],
programId: PublicKey
) => {
const key =
'pda-' +
seeds.reduce((agg, item) => agg + item.toString('hex'), '') +
programId.toString()
const cached = localStorage.getItem(key)
if (cached) {
const value = JSON.parse(cached)
return [new PublicKey(value.key), parseInt(value.nonce)] as [
PublicKey,
number
]
}
const result = await PublicKey.findProgramAddress(seeds, programId)
return [result[0], result[1]] as [PublicKey, number]
}