packages feed

solana-haskell-sdk-1.2.0.0: test/Test/Metaplex/TokenMetadata.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.Metaplex.TokenMetadata (tests) where

import Data.Aeson (FromJSON (..), Value, eitherDecodeFileStrict, withObject, (.:))
import Data.Aeson.Types (parseMaybe)
import Data.Binary (decode, decodeOrFail, encode)
import Data.Binary.Get (ByteOffset)
import Data.ByteString qualified as BS
import Data.ByteString.Base16 qualified as B16
import Data.ByteString.Lazy qualified as BL
import Data.List (find, isInfixOf)
import Data.Maybe (mapMaybe)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Network.Solana.Core.Crypto (SolanaPublicKey, createSolanaKeypairFromSeed, getSolanaPublicKeyRaw, unsafeSolanaPublicKeyRaw)
import Network.Solana.Core.Instruction (AccountMeta (..), iAccounts)
import Network.Solana.Metaplex.TokenMetadata qualified as TM
import Network.Solana.NativePrograms.SystemProgram qualified as SystemProgram
import Network.Solana.SplPrograms.Token qualified as Token
import Network.Solana.Sysvar qualified as Sysvar
import Test.Fixtures
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck

-- Fixed constants (ratified in Task 1 against the mpl-token-metadata crate).

mintPk :: SolanaPublicKey
mintPk = unsafeSolanaPublicKeyRaw (replicate 32 12)

payerPk :: SolanaPublicKey
payerPk =
  case createSolanaKeypairFromSeed (BS.replicate 32 1) of
    Just (pk, _) -> pk
    Nothing -> error "failed to derive payer"

secondCreatorPk :: SolanaPublicKey
secondCreatorPk = unsafeSolanaPublicKeyRaw (replicate 32 33)

collectionKeyPk :: SolanaPublicKey
collectionKeyPk = unsafeSolanaPublicKeyRaw (replicate 32 34)

dataV2Full :: TM.DataV2
dataV2Full =
  TM.DataV2
    { TM.dName = "Solana Haskell NFT",
      TM.dSymbol = "SHSDK",
      TM.dUri = "https://example.com/nft.json",
      TM.dSellerFeeBasisPoints = 550,
      TM.dCreators = Just [TM.Creator payerPk True 60, TM.Creator secondCreatorPk False 40],
      TM.dCollection = Just (TM.Collection False collectionKeyPk),
      TM.dUses = Just (TM.Uses TM.Multiple 10 10)
    }

dataV2Minimal :: TM.DataV2
dataV2Minimal =
  TM.DataV2
    { TM.dName = "Solana Haskell NFT",
      TM.dSymbol = "SHSDK",
      TM.dUri = "https://example.com/nft.json",
      TM.dSellerFeeBasisPoints = 550,
      TM.dCreators = Nothing,
      TM.dCollection = Nothing,
      TM.dUses = Nothing
    }

-- metadata_instruction_data.json mixes two shapes in one array: six
-- {name, hex} entries (the golden vectors) and one {name, accounts} entry
-- (create-v3-full-accounts). Test.Fixtures.loadFixtures decodes the whole
-- file as [Fixture], which requires every element to carry a "hex" field, so
-- it can't be used directly here. Load the file as [Value] instead and pick
-- out each shape locally, without extending the shared loader.

-- | Like 'loadFixtures', but tolerant of the extra {name, accounts} entry:
-- elements that don't parse as a {name, hex} 'Fixture' are simply skipped.
loadDataFixtures :: FilePath -> IO [Fixture]
loadDataFixtures path = do
  vals <- either fail pure =<< eitherDecodeFileStrict path
  pure (mapMaybe (parseMaybe parseJSON) (vals :: [Value]))

-- Local decode of the `create-v3-full-accounts` entry (ground truth for the
-- createMetadataAccountV3 meta test).

data FixtureAccountMeta = FixtureAccountMeta T.Text Bool Bool

instance FromJSON FixtureAccountMeta where
  parseJSON = withObject "FixtureAccountMeta" $ \v ->
    FixtureAccountMeta <$> v .: "pubkey" <*> v .: "signer" <*> v .: "writable"

data AccountsFixture = AccountsFixture
  { afName :: String,
    afAccounts :: [FixtureAccountMeta]
  }

instance FromJSON AccountsFixture where
  parseJSON = withObject "AccountsFixture" $ \v ->
    AccountsFixture <$> v .: "name" <*> v .: "accounts"

loadCreateV3FullAccounts :: FilePath -> IO [AccountMeta]
loadCreateV3FullAccounts path = do
  vals <- either fail pure =<< eitherDecodeFileStrict path
  case find ((== "create-v3-full-accounts") . afName) (mapMaybe (parseMaybe parseJSON) (vals :: [Value])) of
    Just af -> pure (map toAccountMeta (afAccounts af))
    Nothing -> fail "create-v3-full-accounts entry not found"
  where
    toAccountMeta (FixtureAccountMeta hexPubkey signer writable) =
      AccountMeta
        { accountPubKey = unsafeSolanaPublicKeyRaw (BS.unpack (decodeHex hexPubkey)),
          isSigner = signer,
          isWritable = writable
        }
    decodeHex hexPubkey = either (error . ("bad hex in create-v3-full-accounts: " <>)) id (B16.decode (TE.encodeUtf8 hexPubkey))

-- Small QuickCheck generators covering every support type and instruction
-- variant, for the round-trip property below.

genPk :: Gen SolanaPublicKey
genPk = unsafeSolanaPublicKeyRaw <$> vectorOf 32 arbitrary

genAsciiString :: Gen String
genAsciiString = listOf (elements ['a' .. 'z'])

genUseMethod :: Gen TM.UseMethod
genUseMethod = elements [TM.Burn, TM.Multiple, TM.Single]

genCreator :: Gen TM.Creator
genCreator = TM.Creator <$> genPk <*> arbitrary <*> arbitrary

genCollection :: Gen TM.Collection
genCollection = TM.Collection <$> arbitrary <*> genPk

genUses :: Gen TM.Uses
genUses = TM.Uses <$> genUseMethod <*> arbitrary <*> arbitrary

genCollectionDetails :: Gen TM.CollectionDetails
genCollectionDetails = TM.CollectionDetailsV1 <$> arbitrary

genMaybe :: Gen a -> Gen (Maybe a)
genMaybe g = oneof [pure Nothing, Just <$> g]

genDataV2 :: Gen TM.DataV2
genDataV2 =
  TM.DataV2
    <$> genAsciiString
    <*> genAsciiString
    <*> genAsciiString
    <*> arbitrary
    <*> genMaybe (resize 3 (listOf genCreator))
    <*> genMaybe genCollection
    <*> genMaybe genUses

genTokenMetadataInstruction :: Gen TM.TokenMetadataInstruction
genTokenMetadataInstruction =
  oneof
    [ TM.CreateMetadataAccountV3 <$> genDataV2 <*> arbitrary <*> genMaybe genCollectionDetails,
      TM.UpdateMetadataAccountV2
        <$> genMaybe genDataV2
        <*> genMaybe genPk
        <*> genMaybe arbitrary
        <*> genMaybe arbitrary,
      TM.CreateMasterEditionV3 <$> genMaybe arbitrary
    ]

enc :: TM.TokenMetadataInstruction -> BS.ByteString
enc = BL.toStrict . encode

tests :: TestTree
tests =
  withResource (loadDataFixtures "test/fixtures/metadata_instruction_data.json") (const (pure ())) $ \getFixtures ->
    withResource (loadFixtures "test/fixtures/pda.json") (const (pure ())) $ \getPdaFixtures ->
      withResource (loadCreateV3FullAccounts "test/fixtures/metadata_instruction_data.json") (const (pure ())) $ \getCreateV3Accounts ->
        withResource (loadFixtures "test/fixtures/state_fixtures.json") (const (pure ())) $ \getStateFixtures ->
          testGroup
            "Metaplex Token Metadata"
            [ testGroup
                "Metadata account state (golden + rejection)"
                [ testCase "decodeMetadata: metadata-account golden (full tail)" $ do
                    fs <- getStateFixtures
                    let bs = requireFixture "metadata-account" fs
                    case TM.decodeMetadata bs of
                      Left err -> assertFailure $ "decode failed: " <> err
                      Right md -> do
                        TM.mdKey md @?= 4
                        TM.mdUpdateAuthority md @?= payerPk
                        TM.mdMint md @?= mintPk
                        TM.mdName md @?= "Solana Haskell NFT"
                        TM.mdSymbol md @?= "SHSDK"
                        TM.mdUri md @?= "https://example.com/nft.json"
                        TM.mdSellerFeeBasisPoints md @?= 550
                        TM.mdCreators md @?= Just [TM.Creator payerPk True 60, TM.Creator secondCreatorPk False 40]
                        TM.mdPrimarySaleHappened md @?= False
                        TM.mdIsMutable md @?= True
                        TM.mdEditionNonce md @?= Just 253
                        TM.mdTokenStandard md @?= Just 0
                        TM.mdCollection md @?= Just (TM.Collection False collectionKeyPk)
                        TM.mdUses md @?= Just (TM.Uses TM.Multiple 10 10),
                  testCase "decodeMetadata: metadata-account-legacy golden (empty tail)" $ do
                    fs <- getStateFixtures
                    let bs = requireFixture "metadata-account-legacy" fs
                    case TM.decodeMetadata bs of
                      Left err -> assertFailure $ "decode failed: " <> err
                      Right md -> do
                        TM.mdKey md @?= 4
                        TM.mdUpdateAuthority md @?= payerPk
                        TM.mdMint md @?= mintPk
                        TM.mdName md @?= "Solana Haskell NFT"
                        TM.mdSymbol md @?= "SHSDK"
                        TM.mdUri md @?= "https://example.com/nft.json"
                        TM.mdSellerFeeBasisPoints md @?= 550
                        TM.mdCreators md @?= Just [TM.Creator payerPk True 60, TM.Creator secondCreatorPk False 40]
                        TM.mdPrimarySaleHappened md @?= False
                        TM.mdIsMutable md @?= True
                        TM.mdEditionNonce md @?= Nothing
                        TM.mdTokenStandard md @?= Nothing
                        TM.mdCollection md @?= Nothing
                        TM.mdUses md @?= Nothing,
                  testCase "decodeMetadata: rejects empty bytes" $
                    case TM.decodeMetadata BS.empty of
                      Left _ -> pure ()
                      Right _ -> assertFailure "expected decode failure for empty bytes",
                  testCase "decodeMetadata: rejects truncated mid-pubkey (40 bytes)" $
                    case TM.decodeMetadata (BS.replicate 40 0) of
                      Left _ -> pure ()
                      Right _ -> assertFailure "expected decode failure for 40 bytes",
                  testCase "decodeMetadata: rejects bad discriminator (key byte != 4)" $ do
                    fs <- getStateFixtures
                    let bs = requireFixture "metadata-account" fs
                    let corrupted = BS.cons 5 (BS.drop 1 bs)
                    case TM.decodeMetadata corrupted of
                      Left err -> assertBool "error message includes 'unexpected account key'" $
                        "unexpected account key" `isInfixOf` err
                      Right _ -> assertFailure "expected decode failure for bad discriminator"
                ],
              testGroup
                "PDA derivation"
                [ testCase "deriveMetadataAddress matches fixture" $ do
                    fs <- getPdaFixtures
                    case TM.deriveMetadataAddress mintPk of
                      Nothing -> assertFailure "no metadata PDA found"
                      Just addr -> getSolanaPublicKeyRaw addr @?= requireFixture "metadata-address" fs,
                  testCase "deriveMasterEditionAddress matches fixture" $ do
                    fs <- getPdaFixtures
                    case TM.deriveMasterEditionAddress mintPk of
                      Nothing -> assertFailure "no master edition PDA found"
                      Just addr -> getSolanaPublicKeyRaw addr @?= requireFixture "master-edition-address" fs
                ],
              testGroup
                "instruction data (golden)"
                [ goldenCase getFixtures "CreateMetadataAccountV3-full" (TM.CreateMetadataAccountV3 dataV2Full True (Just (TM.CollectionDetailsV1 0))),
                  goldenCase getFixtures "CreateMetadataAccountV3-minimal" (TM.CreateMetadataAccountV3 dataV2Minimal True Nothing),
                  goldenCase getFixtures "UpdateMetadataAccountV2-some" (TM.UpdateMetadataAccountV2 (Just dataV2Full) (Just payerPk) (Just True) (Just True)),
                  goldenCase getFixtures "UpdateMetadataAccountV2-none" (TM.UpdateMetadataAccountV2 Nothing Nothing Nothing Nothing),
                  goldenCase getFixtures "CreateMasterEditionV3-some" (TM.CreateMasterEditionV3 (Just 100)),
                  goldenCase getFixtures "CreateMasterEditionV3-none" (TM.CreateMasterEditionV3 Nothing)
                ],
              testGroup
                "properties"
                [ testProperty "Binary round-trip" $
                    forAll genTokenMetadataInstruction $ \i -> decode (encode i) === i,
                  testCase "decode fails on unknown discriminant" $
                    case decodeOrFail (BL.pack [200]) :: Either (BL.ByteString, ByteOffset, String) (BL.ByteString, ByteOffset, TM.TokenMetadataInstruction) of
                      Left _ -> pure ()
                      Right _ -> assertFailure "expected decode failure for discriminant 200"
                ],
              testGroup
                "builder metas"
                [ testCase "createMetadataAccountV3 matches create-v3-full-accounts fixture" $ do
                    expected <- getCreateV3Accounts
                    iAccounts (TM.createMetadataAccountV3 mintPk payerPk payerPk payerPk True dataV2Full True (Just (TM.CollectionDetailsV1 0)))
                      @?= expected,
                  testCase "updateMetadataAccountV2 metas" $
                    case TM.deriveMetadataAddress mintPk of
                      Nothing -> assertFailure "no metadata PDA found"
                      Just metadataAddr ->
                        iAccounts (TM.updateMetadataAccountV2 mintPk payerPk Nothing Nothing Nothing Nothing)
                          @?= [ AccountMeta {accountPubKey = metadataAddr, isSigner = False, isWritable = True},
                                AccountMeta {accountPubKey = payerPk, isSigner = True, isWritable = False}
                              ],
                  testCase "createMasterEditionV3 metas" $
                    case (TM.deriveMasterEditionAddress mintPk, TM.deriveMetadataAddress mintPk) of
                      (Just editionAddr, Just metadataAddr) ->
                        iAccounts (TM.createMasterEditionV3 mintPk payerPk payerPk payerPk (Just 100))
                          @?= [ AccountMeta {accountPubKey = editionAddr, isSigner = False, isWritable = True},
                                AccountMeta {accountPubKey = mintPk, isSigner = False, isWritable = True},
                                AccountMeta {accountPubKey = payerPk, isSigner = True, isWritable = False},
                                AccountMeta {accountPubKey = payerPk, isSigner = True, isWritable = False},
                                AccountMeta {accountPubKey = payerPk, isSigner = True, isWritable = True},
                                AccountMeta {accountPubKey = metadataAddr, 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}
                              ]
                      _ -> assertFailure "PDA derivation failed"
                ]
            ]
  where
    goldenCase getFixtures name i =
      testCase name $ do
        fs <- getFixtures
        enc i @?= requireFixture name fs