packages feed

solana-haskell-sdk-1.2.0.0: src/Network/Solana/Metaplex/TokenMetadata.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Client for the Metaplex Token Metadata program: attaches on-chain
-- metadata (name, symbol, URI, creators, royalties) to SPL Token mints and
-- registers "master edition" NFTs. Instruction data is Borsh-encoded (see
-- "Network.Solana.Core.Borsh"), unlike the native and SPL programs covered
-- elsewhere in this SDK, which use bincode or hand-rolled formats.
--
-- Only three instructions are modeled: 'createMetadataAccountV3',
-- 'updateMetadataAccountV2' and 'createMasterEditionV3'. The rest of the
-- program (50+ instructions, programmable NFTs, delegates, Token-2022
-- support) is out of scope.
module Network.Solana.Metaplex.TokenMetadata
  ( tokenMetadataProgramId,
    deriveMetadataAddress,
    deriveMasterEditionAddress,
    UseMethod (..),
    Creator (..),
    Collection (..),
    Uses (..),
    CollectionDetails (..),
    DataV2 (..),
    TokenMetadataInstruction (..),
    createMetadataAccountV3,
    updateMetadataAccountV2,
    createMasterEditionV3,
    Metadata (..),
    decodeMetadata,
  )
where

import Data.Binary
import Data.Binary.Get
import Data.Binary.Put
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.List (isPrefixOf)
import Data.Maybe (fromMaybe)
import GHC.Generics (Generic)
import Network.Solana.Core.Borsh
import Network.Solana.Core.Crypto
import Network.Solana.Core.Instruction
import Network.Solana.Core.Pda (findProgramAddress)
import Network.Solana.NativePrograms.SystemProgram qualified as SystemProgram
import Network.Solana.SplPrograms.Token qualified as Token
import Network.Solana.Sysvar qualified as Sysvar

-- | Metaplex Token Metadata program address.
tokenMetadataProgramId :: SolanaPublicKey
tokenMetadataProgramId = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"

-- | Derive the metadata PDA for a mint: the PDA of
-- @["metadata", program id, mint]@ under the Token Metadata program.
deriveMetadataAddress :: SolanaPublicKey -> Maybe SolanaPublicKey
deriveMetadataAddress mint =
  fst
    <$> findProgramAddress
      [ "metadata",
        getSolanaPublicKeyRaw tokenMetadataProgramId,
        getSolanaPublicKeyRaw mint
      ]
      tokenMetadataProgramId

-- | Derive the master edition PDA for a mint: the PDA of
-- @["metadata", program id, mint, "edition"]@ under the Token Metadata program.
deriveMasterEditionAddress :: SolanaPublicKey -> Maybe SolanaPublicKey
deriveMasterEditionAddress mint =
  fst
    <$> findProgramAddress
      [ "metadata",
        getSolanaPublicKeyRaw tokenMetadataProgramId,
        getSolanaPublicKeyRaw mint,
        "edition"
      ]
      tokenMetadataProgramId

-- | Which "uses" behavior an NFT carries (Borsh u8 enum index 0\/1\/2).
data UseMethod = Burn | Multiple | Single
  deriving (Eq, Show, Enum, Bounded, Generic)

putUseMethod :: UseMethod -> Put
putUseMethod = putWord8 . fromIntegral . fromEnum

getUseMethod :: Get UseMethod
getUseMethod = do
  b <- getWord8
  if b <= 2
    then pure (toEnum (fromIntegral b))
    else fail ("UseMethod: invalid value " <> show b)

-- | A single NFT creator entry: address, whether they have verified their
-- inclusion, and their royalty share (0-100).
data Creator = Creator
  { crAddress :: SolanaPublicKey,
    crVerified :: Bool,
    crShare :: Word8
  }
  deriving (Eq, Show, Generic)

instance Binary Creator where
  put :: Creator -> Put
  put (Creator addr verified share) = do
    putByteString (getSolanaPublicKeyRaw addr)
    putBorshBool verified
    putWord8 share
  get :: Get Creator
  get = Creator <$> get <*> getBorshBool <*> getWord8

-- | The collection an NFT belongs to.
data Collection = Collection
  { colVerified :: Bool,
    colKey :: SolanaPublicKey
  }
  deriving (Eq, Show, Generic)

instance Binary Collection where
  put :: Collection -> Put
  put (Collection verified key) = do
    putBorshBool verified
    putByteString (getSolanaPublicKeyRaw key)
  get :: Get Collection
  get = Collection <$> getBorshBool <*> get

-- | Limited-use configuration for an NFT (e.g. burn-on-use tickets).
data Uses = Uses
  { usesUseMethod :: UseMethod,
    usesRemaining :: Word64,
    usesTotal :: Word64
  }
  deriving (Eq, Show, Generic)

instance Binary Uses where
  put :: Uses -> Put
  put (Uses method usesRemaining' total) = do
    putUseMethod method
    putWord64le usesRemaining'
    putWord64le total
  get :: Get Uses
  get = Uses <$> getUseMethod <*> getWord64le <*> getWord64le

-- | Marks a Metadata account as a collection ("sized" collection, V1). Only
-- the V1 variant is modeled; decoding any other variant tag fails.
newtype CollectionDetails = CollectionDetailsV1 {cdSize :: Word64}
  deriving (Eq, Show, Generic)

instance Binary CollectionDetails where
  put :: CollectionDetails -> Put
  put (CollectionDetailsV1 size) = do
    putWord8 0
    putWord64le size
  get :: Get CollectionDetails
  get = do
    variant <- getWord8
    case variant of
      0 -> CollectionDetailsV1 <$> getWord64le
      _ -> fail ("CollectionDetails: unsupported variant " <> show variant)

-- | The mutable on-chain fields of a Metadata account.
data DataV2 = DataV2
  { dName :: String,
    dSymbol :: String,
    dUri :: String,
    dSellerFeeBasisPoints :: Word16,
    dCreators :: Maybe [Creator],
    dCollection :: Maybe Collection,
    dUses :: Maybe Uses
  }
  deriving (Eq, Show, Generic)

instance Binary DataV2 where
  put :: DataV2 -> Put
  put (DataV2 name symbol uri fee creators collection uses) = do
    putBorshString name
    putBorshString symbol
    putBorshString uri
    putWord16le fee
    putBorshOption (putBorshVec put) creators
    putBorshOption put collection
    putBorshOption put uses
  get :: Get DataV2
  get =
    DataV2
      <$> getBorshString
      <*> getBorshString
      <*> getBorshString
      <*> getWord16le
      <*> getBorshOption (getBorshVec get)
      <*> getBorshOption get
      <*> getBorshOption get

-- | On-chain state of a Metadata account (the @Metadata@ struct from
-- @mpl-token-metadata@). The head fields are always present; the tail
-- (edition nonce, token standard, collection, uses) was added across
-- several program upgrades, so older accounts simply end early.
data Metadata = Metadata
  { mdKey :: Word8,
    mdUpdateAuthority :: SolanaPublicKey,
    mdMint :: SolanaPublicKey,
    mdName :: String,
    mdSymbol :: String,
    mdUri :: String,
    mdSellerFeeBasisPoints :: Word16,
    mdCreators :: Maybe [Creator],
    mdPrimarySaleHappened :: Bool,
    mdIsMutable :: Bool,
    mdEditionNonce :: Maybe Word8,
    mdTokenStandard :: Maybe Word8,
    mdCollection :: Maybe Collection,
    mdUses :: Maybe Uses
  }
  deriving (Eq, Show)

-- | Strips trailing NUL padding from a Borsh string field. @name@, @symbol@
-- and @uri@ are stored in fixed-size, NUL-padded buffers on-chain.
trimNuls :: String -> String
trimNuls = reverse . dropWhile (== '\0') . reverse

-- | Decodes a Metadata account's data: a Borsh-encoded @key@, two pubkeys,
-- the three NUL-padded name\/symbol\/uri strings, royalty fee, creators,
-- and the @primarySaleHappened@\/@isMutable@ flags, followed by a tolerant
-- tail of @editionNonce@, @tokenStandard@, @collection@ and @uses@: each is
-- read only if bytes remain, and any bytes left over after @uses@ (account
-- padding) are ignored. Fails if the key byte is not 4 (MetadataV1).
decodeMetadata :: BS.ByteString -> Either String Metadata
decodeMetadata bs = case runGetOrFail getMetadata (BL.fromStrict bs) of
  Left (_, _, err) -> Left (prefixError err)
  Right (_, _, m) -> Right m
  where
    prefixError err =
      if "Metadata:" `isPrefixOf` err then err else "Metadata: " <> err
    getMetadata = do
      k <- getWord8
      if k /= 4
        then fail ("Metadata: unexpected account key " <> show k <> " (expected 4)")
        else Metadata k
          <$> get
          <*> get
          <*> (trimNuls <$> getBorshString)
          <*> (trimNuls <$> getBorshString)
          <*> (trimNuls <$> getBorshString)
          <*> getWord16le
          <*> getBorshOption (getBorshVec get)
          <*> getBorshBool
          <*> getBorshBool
          <*> getTolerantOption getWord8
          <*> getTolerantOption getWord8
          <*> getTolerantOption get
          <*> getTolerantOption get
    getTolerantOption getVal = do
      e <- isEmpty
      if e then pure Nothing else getBorshOption getVal

-- | Token Metadata instructions covered by this client (Borsh u8
-- discriminants, arbitrated by the @mpl-token-metadata@ crate's fixtures).
data TokenMetadataInstruction
  = -- | Create a Metadata account (v3) for a mint. Discriminant 33.
    CreateMetadataAccountV3
      { cmaData :: DataV2,
        cmaIsMutable :: Bool,
        cmaCollectionDetails :: Maybe CollectionDetails
      }
  | -- | Update an existing Metadata account (v2). Discriminant 15.
    UpdateMetadataAccountV2
      { umaData :: Maybe DataV2,
        umaUpdateAuthority :: Maybe SolanaPublicKey,
        umaPrimarySaleHappened :: Maybe Bool,
        umaIsMutable :: Maybe Bool
      }
  | -- | Register a Metadata account as a Master Edition (v3). Discriminant 17.
    CreateMasterEditionV3
      { cmeMaxSupply :: Maybe Word64
      }
  deriving (Eq, Show, Generic)

instance Binary TokenMetadataInstruction where
  put :: TokenMetadataInstruction -> Put
  put (CreateMetadataAccountV3 dataV2 isMutable collectionDetails) = do
    putWord8 33
    put dataV2
    putBorshBool isMutable
    putBorshOption put collectionDetails
  put (UpdateMetadataAccountV2 dataV2 updateAuthority primarySaleHappened isMutable) = do
    putWord8 15
    putBorshOption put dataV2
    putBorshOption (putByteString . getSolanaPublicKeyRaw) updateAuthority
    putBorshOption putBorshBool primarySaleHappened
    putBorshOption putBorshBool isMutable
  put (CreateMasterEditionV3 maxSupply) = do
    putWord8 17
    putBorshOption putWord64le maxSupply

  get :: Get TokenMetadataInstruction
  get = do
    disc <- getWord8
    case disc of
      33 -> CreateMetadataAccountV3 <$> get <*> getBorshBool <*> getBorshOption get
      15 ->
        UpdateMetadataAccountV2
          <$> getBorshOption get
          <*> getBorshOption get
          <*> getBorshOption getBorshBool
          <*> getBorshOption getBorshBool
      17 -> CreateMasterEditionV3 <$> getBorshOption getWord64le
      _ -> fail ("TokenMetadataInstruction: unknown discriminant " <> show disc)

-- | Create a new Metadata account (v3) for a mint.
-- Fails with 'error' if PDA derivation fails (established precedent, see
-- "Network.Solana.SplPrograms.AssociatedTokenAccount").
-- # Account references (no rent account — matches the reference crate,
-- which omits the optional rent account entirely when unset)
-- 0. `[WRITE]` Metadata account (derived)
-- 1. `[]` Mint account
-- 2. `[SIGNER]` Mint authority
-- 3. `[WRITE, SIGNER]` Payer
-- 4. `[]` Update authority (signer iff @updateAuthorityIsSigner@)
-- 5. `[]` System program
createMetadataAccountV3 :: SolanaPublicKey -> SolanaPublicKey -> SolanaPublicKey -> SolanaPublicKey -> Bool -> DataV2 -> Bool -> Maybe CollectionDetails -> Instruction
createMetadataAccountV3 mint mintAuthority payer updateAuthority updateAuthorityIsSigner dataV2 isMutable collectionDetails =
  mkInstruction
    tokenMetadataProgramId
    [ AccountMeta {accountPubKey = metadata, isSigner = False, isWritable = True},
      AccountMeta {accountPubKey = mint, isSigner = False, isWritable = False},
      AccountMeta {accountPubKey = mintAuthority, isSigner = True, isWritable = False},
      AccountMeta {accountPubKey = payer, isSigner = True, isWritable = True},
      AccountMeta {accountPubKey = updateAuthority, isSigner = updateAuthorityIsSigner, isWritable = False},
      AccountMeta {accountPubKey = SystemProgram.systemProgramId, isSigner = False, isWritable = False}
    ]
    (CreateMetadataAccountV3 dataV2 isMutable collectionDetails)
  where
    metadata = fromMaybe (error "createMetadataAccountV3: metadata PDA derivation failed") (deriveMetadataAddress mint)

-- | Update an existing Metadata account (v2).
-- # Account references
-- 0. `[WRITE]` Metadata account (derived)
-- 1. `[SIGNER]` Update authority
updateMetadataAccountV2 :: SolanaPublicKey -> SolanaPublicKey -> Maybe DataV2 -> Maybe SolanaPublicKey -> Maybe Bool -> Maybe Bool -> Instruction
updateMetadataAccountV2 mint updateAuthority dataV2 newUpdateAuthority primarySaleHappened isMutable =
  mkInstruction
    tokenMetadataProgramId
    [ AccountMeta {accountPubKey = metadata, isSigner = False, isWritable = True},
      AccountMeta {accountPubKey = updateAuthority, isSigner = True, isWritable = False}
    ]
    (UpdateMetadataAccountV2 dataV2 newUpdateAuthority primarySaleHappened isMutable)
  where
    metadata = fromMaybe (error "updateMetadataAccountV2: metadata PDA derivation failed") (deriveMetadataAddress mint)

-- | Register a Metadata account as a Master Edition (v3), fixing the
-- maximum number of print editions (or making it unlimited when 'Nothing').
-- Fails with 'error' if PDA derivation fails (see 'createMetadataAccountV3').
-- # Account references
-- 0. `[WRITE]` Master Edition account (derived)
-- 1. `[WRITE]` Mint account
-- 2. `[SIGNER]` Update authority
-- 3. `[SIGNER]` Mint authority
-- 4. `[WRITE, SIGNER]` Payer
-- 5. `[]` Metadata account (derived)
-- 6. `[]` Token program
-- 7. `[]` System program
-- 8. `[]` Rent sysvar
createMasterEditionV3 :: SolanaPublicKey -> SolanaPublicKey -> SolanaPublicKey -> SolanaPublicKey -> Maybe Word64 -> Instruction
createMasterEditionV3 mint updateAuthority mintAuthority payer maxSupply =
  mkInstruction
    tokenMetadataProgramId
    [ AccountMeta {accountPubKey = edition, isSigner = False, isWritable = True},
      AccountMeta {accountPubKey = mint, isSigner = False, isWritable = True},
      AccountMeta {accountPubKey = updateAuthority, isSigner = True, isWritable = False},
      AccountMeta {accountPubKey = mintAuthority, isSigner = True, isWritable = False},
      AccountMeta {accountPubKey = payer, isSigner = True, isWritable = True},
      AccountMeta {accountPubKey = metadata, isSigner = False, isWritable = False},
      AccountMeta {accountPubKey = Token.tokenProgramId, isSigner = False, isWritable = False},
      AccountMeta {accountPubKey = SystemProgram.systemProgramId, isSigner = False, isWritable = False},
      AccountMeta {accountPubKey = Sysvar.rent, isSigner = False, isWritable = False}
    ]
    (CreateMasterEditionV3 maxSupply)
  where
    edition = fromMaybe (error "createMasterEditionV3: master edition PDA derivation failed") (deriveMasterEditionAddress mint)
    metadata = fromMaybe (error "createMasterEditionV3: metadata PDA derivation failed") (deriveMetadataAddress mint)