import { MPL_BUBBLEGUM_PROGRAM_ID, SPL_ACCOUNT_COMPRESSION_PROGRAM_ID, SPL_NOOP_PROGRAM_ID, createTree, getMintToCollectionV1InstructionDataSerializer, mplBubblegum, setTreeDelegate, } from '@metaplex-foundation/mpl-bubblegum'; import * as Umi from '@metaplex-foundation/umi'; import { createSignerFromKeypair, generateSigner, percentAmount, signerIdentity, } from '@metaplex-foundation/umi'; import { createUmi } from '@metaplex-foundation/umi-bundle-defaults'; import { fromWeb3JsKeypair, fromWeb3JsPublicKey, toWeb3JsPublicKey, } from '@metaplex-foundation/umi-web3js-adapters'; import { readFileSync } from 'fs'; import { MPL_TOKEN_METADATA_PROGRAM_ID, TokenStandard, createNft, delegateCollectionV1, findMetadataPda, mplTokenMetadata, } from '@metaplex-foundation/mpl-token-metadata'; import { AddressLookupTableAccount, ComputeBudgetProgram, Connection, Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, } from '@solana/web3.js'; import * as anchor from '@staratlas/anchor'; import { AnchorProvider, Program, Wallet } from '@staratlas/anchor'; import { Rarity } from '@staratlas/crew-data'; import { decodeUri } from '@staratlas/crew-utils'; import { FixedSizeArray, airdrop, buildSendAndCheck, createAndExtendAddressLookupTable, createAssociatedTokenAccount, createMint, ixToIxReturn, keypairToAsyncSigner, mintToTokenAccount, readFromRPCNullable, readFromRPCOrError, transfer, } from '@staratlas/data-source'; import { PLAYER_PROFILE_IDL } from '@staratlas/player-profile'; import base58 from 'bs58'; import { createHash } from 'node:crypto'; import path from 'path'; import { BitViewer, CREW_IDL, CrewConfig, CrewPermissions, Faction, PackTiers, PackType, RegisterPackTiersInput, RegisterPackTypeInput, RegisterSftRedemptionInput, SftRedemption, UserRedemption, crewConfigEquals, crewErrorMap, mintCrewMember, packTiersEquals, packTypeEquals, redeemCrewPack, registerCrewConfig, registerPackTiers, registerPackType, registerSftRedemption, sftRedemptionEquals, userRedemptionEquals, } from '../src'; import { TREE_CANOPY_DEPTH, TREE_MAX_BUFFER_SIZE, TREE_MAX_DEPTH, TREE_OWNER, createProfile, } from './helpers'; describe('CrewMint', () => { const connection = new Connection('http://localhost:8899', 'confirmed'); const walletKeypair = Keypair.generate(); const walletSigner = keypairToAsyncSigner(walletKeypair); const provider = new AnchorProvider( connection, new Wallet(walletKeypair), AnchorProvider.defaultOptions(), ); const program = new Program( CREW_IDL, new PublicKey('CrewCfY4Na9HBj7UAJ9Yi9AteoEYPhEs8sg6YnjK4qBL'), provider, ); const playerProfileProgram = new Program( PLAYER_PROFILE_IDL, new PublicKey('PprofUW1pURCnMW2si88GWPXEEK3Bvh9Tksy8WtnoYJ'), provider, ); const admin = keypairToAsyncSigner(Keypair.generate()); const funderKey = Keypair.generate(); const funder = keypairToAsyncSigner(funderKey); const user = keypairToAsyncSigner(Keypair.generate()); const packTiersManagerKey = keypairToAsyncSigner(Keypair.generate()); const packTypesManagerKey = keypairToAsyncSigner(Keypair.generate()); const sftRedemptionManagerKey = keypairToAsyncSigner(Keypair.generate()); const mintCrewCardsManagerKey = keypairToAsyncSigner(Keypair.generate()); const provideServerHashManagerKey = keypairToAsyncSigner(Keypair.generate()); const crewPackMint = keypairToAsyncSigner(Keypair.generate()); const crewProfile = keypairToAsyncSigner(Keypair.generate()); const userRedemptionSeed = Keypair.generate().publicKey; const userRedemption0Seed = Keypair.generate().publicKey; const umi = createUmi('http://localhost:8899', { commitment: 'confirmed' }) .use(mplTokenMetadata()) .use(mplBubblegum()); const umiSigner = generateSigner(umi); const umiPubkey = toWeb3JsPublicKey(umiSigner.publicKey); umi.use(signerIdentity(umiSigner)); let userCrewPackSftAccount: PublicKey; const [crewConfigPubkey, crewConfigBump] = CrewConfig.findAddress(program); const packTiersSeedPubkey = Keypair.generate().publicKey; const [packTiersPubkey, _packTiersBump] = PackTiers.findAddress(program, { seedPubkey: packTiersSeedPubkey, packTier: 1, }); const [packTypePubkey, packTypeBump] = PackType.findAddress(program, { packTiers: packTiersPubkey, faction: Faction.MUD, }); const redemptionAmount = 10; const [sftRedemptionPubkey, sftRedemptionBump] = SftRedemption.findAddress( program, { packType: packTypePubkey, sftMint: crewPackMint.publicKey(), redemptionAmount, }, ); const [userRedemptionPubkey, userRedemptionBump] = UserRedemption.findAddress( program, { owner: user.publicKey(), packType: packTypePubkey, seedPubkey: userRedemptionSeed, }, ); const [userRedemption0Pubkey, userRedemption0Bump] = UserRedemption.findAddress(program, { owner: user.publicKey(), packType: packTypePubkey, seedPubkey: userRedemption0Seed, }); const inputQty0 = 5; const inputQty = 1; const collectionMintKeypair = fromWeb3JsKeypair( anchor.web3.Keypair.fromSecretKey( Buffer.from( JSON.parse( readFileSync(path.resolve(__dirname, './crew_mint-keypair.json'), { encoding: 'utf-8', }), ), ), ), ); const collectionMint = createSignerFromKeypair(umi, collectionMintKeypair); const lookupTables: AddressLookupTableAccount[] = []; const merkleTrees: PublicKey[] = []; // these correspond to keys in _crewCraftingProfile const PACK_TIERS_MANAGER = 1; const PACK_TYPES_MANAGER = 2; const SFT_REDEMPTION_MANAGER = 3; const MINT_CREW_CARDS = 4; const PROVIDE_SERVER_HASH = 5; const _createCrewProfile = async (profileId = crewProfile) => { await createProfile( playerProfileProgram, profileId, funder, [ { key: packTiersManagerKey.publicKey(), expireTime: null, scope: program.programId, permissions: CrewPermissions.managePackTiers(), }, { key: packTypesManagerKey.publicKey(), expireTime: null, scope: program.programId, permissions: CrewPermissions.registerPackType(), }, { key: sftRedemptionManagerKey.publicKey(), expireTime: null, scope: program.programId, permissions: CrewPermissions.registerSftRedemption(), }, { key: mintCrewCardsManagerKey.publicKey(), expireTime: null, scope: program.programId, permissions: CrewPermissions.mintCrewCards(), }, { key: provideServerHashManagerKey.publicKey(), expireTime: null, scope: program.programId, permissions: CrewPermissions.provideServerHash(), }, ], provider.connection, admin, walletSigner, ); }; const CREW_URI_PREFIX = 'https://api.crew.staratlas.com/'; const SERVER_PREIMAGE_KEY = Keypair.generate().publicKey; const SERVER_HASH_BYTES = [ ...createHash('sha256').update(SERVER_PREIMAGE_KEY.toBytes()).digest(), ] as FixedSizeArray; beforeAll(async () => { await Promise.all([ airdrop(connection, walletSigner.publicKey(), LAMPORTS_PER_SOL * 10), airdrop(connection, umiPubkey, LAMPORTS_PER_SOL * 1000), airdrop(connection, admin.publicKey(), LAMPORTS_PER_SOL * 10), airdrop(connection, funder.publicKey(), LAMPORTS_PER_SOL * 10), airdrop(connection, user.publicKey(), LAMPORTS_PER_SOL * 10), ]); const ix1 = createAssociatedTokenAccount( crewPackMint.publicKey(), user.publicKey(), ); userCrewPackSftAccount = ix1.address; await buildSendAndCheck( [ createMint(crewPackMint, 0, admin.publicKey(), null), ix1.instructions, mintToTokenAccount( admin, crewPackMint.publicKey(), userCrewPackSftAccount, 10, ), ], walletSigner, provider.connection, { sendOptions: { skipPreflight: true, }, }, ); await Promise.all( new Array(10).fill(0).map(async (_, index) => { const merkleTreeKeypair = fromWeb3JsKeypair( anchor.web3.Keypair.fromSecretKey( Buffer.from( JSON.parse( readFileSync( path.resolve(__dirname, `./tree-keys/key${index}.json`), { encoding: 'utf-8', }, ), ), ), ), ); const merkleTreeSigner = Umi.createSignerFromKeypair( umi, merkleTreeKeypair, ); const builder: Umi.TransactionBuilder = await createTree(umi, { public: false, merkleTree: merkleTreeSigner, maxDepth: TREE_MAX_DEPTH, maxBufferSize: TREE_MAX_BUFFER_SIZE, canopyDepth: TREE_CANOPY_DEPTH, payer: umi.identity, treeCreator: createSignerFromKeypair( umi, fromWeb3JsKeypair(TREE_OWNER), ), }); await builder.sendAndConfirm(umi); await setTreeDelegate(umi, { merkleTree: merkleTreeSigner.publicKey, treeCreator: createSignerFromKeypair( umi, fromWeb3JsKeypair(TREE_OWNER), ), newTreeDelegate: fromWeb3JsPublicKey(crewConfigPubkey), }).sendAndConfirm(umi); merkleTrees.push(toWeb3JsPublicKey(merkleTreeKeypair.publicKey)); }), ); await createNft(umi, { mint: collectionMint, name: 'Crew Collection', uri: 'https://example.com/my-nft.json', sellerFeeBasisPoints: percentAmount(5.5), isCollection: true, }).sendAndConfirm(umi); await delegateCollectionV1(umi, { mint: collectionMint.publicKey, delegate: fromWeb3JsPublicKey(crewConfigPubkey), tokenStandard: TokenStandard.NonFungible, }).sendAndConfirm(umi); await _createCrewProfile(); const metadata = findMetadataPda(umi, { mint: collectionMint.publicKey, })[0]; const newLookup = ( await createAndExtendAddressLookupTable(connection, admin, admin, [ ...merkleTrees, toWeb3JsPublicKey(SPL_ACCOUNT_COMPRESSION_PROGRAM_ID), toWeb3JsPublicKey(MPL_BUBBLEGUM_PROGRAM_ID), toWeb3JsPublicKey(SPL_NOOP_PROGRAM_ID), toWeb3JsPublicKey(MPL_TOKEN_METADATA_PROGRAM_ID), toWeb3JsPublicKey(collectionMint.publicKey), toWeb3JsPublicKey(metadata), SystemProgram.programId, crewConfigPubkey, crewProfile.publicKey(), ]) )._unsafeUnwrap(); const table = await connection.getAddressLookupTable(newLookup); if (table.value === null) { throw new Error('Address lookup table not found'); } lookupTables.push(table.value); }); it('Registers a Crew Config account', async () => { const ix = registerCrewConfig( program, crewProfile.publicKey(), { namePrefix: 'Test', symbol: 'Test', uriPrefix: CREW_URI_PREFIX, sellerFeeBasisPoints: 20, collection: toWeb3JsPublicKey(collectionMint.publicKey), creators: [{ key: Keypair.generate().publicKey, share: 100 }], }, merkleTrees, ); await buildSendAndCheck(ix, walletSigner, provider.connection); const crewConfigAccount = await readFromRPCOrError( provider.connection, program, crewConfigPubkey, CrewConfig, 'confirmed', ); //TODO - This test won't actually pass (sad) expect( crewConfigEquals(crewConfigAccount.data, { version: 0, bump: crewConfigBump, profile: crewProfile.publicKey(), seedPubkey: PublicKey.default, namePrefixLen: 2, symbolLen: 2, namePrefix: CrewConfig.namePrefixBuffer('Crew #'), totalMinted: 0, totalAllocated: 0, symbol: CrewConfig.symbolBuffer('CREW'), uriPrefix: CrewConfig.uriPrefixBuffer( 'https://api.crew.staratlas.com/', ), uriPrefixLen: 2, sellerFeeBasisPoints: 20, collectionMint: Keypair.generate().publicKey, creators: [ { key: Keypair.generate().publicKey, share: 100 }, { key: PublicKey.default, share: 0, }, { key: PublicKey.default, share: 0, }, { key: PublicKey.default, share: 0, }, ], creatorCount: 1, }), ); const trees = crewConfigAccount.merkleTrees; expect(trees.length).toBe(10); for (let i = 0; i < 10; i++) { expect(trees[i].equals(merkleTrees[i])).toBe(true); } }); it('Creates a PackTiers account', async () => { const packTiersInput: RegisterPackTiersInput = { keyIndex: PACK_TIERS_MANAGER, packTier: 1, seedPubkey: packTiersSeedPubkey, common: 0, uncommon: 0, rare: 0, epic: 0, legendary: 1, anomaly: 0, }; const ix = registerPackTiers( program, packTiersManagerKey, crewProfile.publicKey(), packTiersInput, ); await buildSendAndCheck(ix, walletSigner, provider.connection); const packTiersAccount = await readFromRPCOrError( provider.connection, program, packTiersPubkey, PackTiers, 'confirmed', ); expect( packTiersEquals(packTiersAccount.data, { version: 0, seedPubkey: packTiersSeedPubkey, crewConfig: crewConfigPubkey, tier: 1, bump: packTypeBump, common: 0, uncommon: 0, rare: 0, epic: 0, legendary: 1, anomaly: 0, }), ); }); it('Creates a PackType account', async () => { const packTypeInput: RegisterPackTypeInput = { keyIndex: PACK_TYPES_MANAGER, faction: Faction.MUD, }; const ix = registerPackType( program, packTypesManagerKey, crewProfile.publicKey(), packTiersPubkey, packTypeInput, ); await buildSendAndCheck(ix, walletSigner, provider.connection); const packTypeAccount = await readFromRPCOrError( provider.connection, program, packTypePubkey, PackType, 'confirmed', ); expect( packTypeEquals(packTypeAccount.data, { version: 0, bump: packTypeBump, crewConfig: crewConfigPubkey, packTiers: packTiersPubkey, faction: Faction.MUD, }), ); }); it('Registers an SFT redemption account', async () => { const sftRedemptionInput: RegisterSftRedemptionInput = { redemptionAmount, keyIndex: SFT_REDEMPTION_MANAGER, }; const ix = registerSftRedemption( program, sftRedemptionManagerKey, crewProfile.publicKey(), packTypePubkey, crewPackMint.publicKey(), sftRedemptionInput, ); await buildSendAndCheck(ix, walletSigner, provider.connection, { sendOptions: { skipPreflight: true }, suppressLogging: true, }); const sftRedemptionAccount = await readFromRPCOrError( provider.connection, program, sftRedemptionPubkey, SftRedemption, ); expect( sftRedemptionEquals(sftRedemptionAccount.data, { version: 0, bump: sftRedemptionBump, packType: packTypePubkey, crewConfig: crewConfigPubkey, sftMint: crewPackMint.publicKey(), redemptionAmount, }), ); }); it('User can redeem a crew pack for an intermediary account', async () => { // ----------------------------- // -- Create first redemption -- // ----------------------------- const ix0 = redeemCrewPack( program, user, crewProfile.publicKey(), provideServerHashManagerKey, user.publicKey(), sftRedemptionPubkey, packTypePubkey, packTiersPubkey, userCrewPackSftAccount, crewPackMint.publicKey(), { keyIndex: PROVIDE_SERVER_HASH, serverHash: SERVER_HASH_BYTES, quantity: inputQty0, seedPubkey: userRedemption0Seed, sageProfile: null, }, ); await buildSendAndCheck(ix0, user, provider.connection, { sendOptions: { skipPreflight: true }, suppressLogging: true, }); const userRedemption0Account = await readFromRPCOrError( provider.connection, program, userRedemption0Pubkey, UserRedemption, ); expect( userRedemptionEquals(userRedemption0Account.data, { version: 0, bump: userRedemption0Bump, crewConfig: crewConfigPubkey, seedPubkey: userRedemption0Seed, owner: user.publicKey(), amount: inputQty0 * redemptionAmount, numberMinted: 0, mintOffset: 0, packType: packTypePubkey, serverHash: SERVER_HASH_BYTES, sageProfile: PublicKey.default, userSeed: userRedemption0Account.data.userSeed, }), ); let crewConfigAccount = await readFromRPCOrError( provider.connection, program, crewConfigPubkey, CrewConfig, ); expect(crewConfigAccount.data.totalAllocated).toEqual( inputQty0 * redemptionAmount, ); // -------------------------- // -- End first redemption -- // -------------------------- const redeemIx = redeemCrewPack( program, user, crewProfile.publicKey(), provideServerHashManagerKey, user.publicKey(), sftRedemptionPubkey, packTypePubkey, packTiersPubkey, userCrewPackSftAccount, crewPackMint.publicKey(), { keyIndex: PROVIDE_SERVER_HASH, serverHash: SERVER_HASH_BYTES, quantity: inputQty, seedPubkey: userRedemptionSeed, sageProfile: null, }, ); // ensure only the user redeem ix can be in the txn const badTransfer = transfer(user, user.publicKey(), 0); await expect( buildSendAndCheck([redeemIx, badTransfer], user, provider.connection, { sendOptions: { skipPreflight: true }, suppressLogging: true, }), ).rejects.toThrow(); await expect( buildSendAndCheck([badTransfer, redeemIx], user, provider.connection, { sendOptions: { skipPreflight: true }, suppressLogging: true, }), ).rejects.toThrow(); const computePrice = ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 426, }); await buildSendAndCheck( [ixToIxReturn(computePrice), redeemIx], user, provider.connection, { sendOptions: { skipPreflight: true }, suppressLogging: true, }, ); const userRedemptionAccount = await readFromRPCOrError( provider.connection, program, userRedemptionPubkey, UserRedemption, ); expect( userRedemptionEquals(userRedemptionAccount.data, { version: 0, bump: userRedemptionBump, seedPubkey: userRedemptionSeed, owner: user.publicKey(), crewConfig: crewConfigPubkey, amount: inputQty * redemptionAmount, numberMinted: 0, mintOffset: redemptionAmount * inputQty0, packType: packTypePubkey, serverHash: SERVER_HASH_BYTES, sageProfile: PublicKey.default, userSeed: userRedemptionAccount.data.userSeed, }), ); const amount = userRedemptionAccount.data.amount; for (let i = 0; i < amount; i++) { expect(userRedemptionAccount.mintBits.get(i)).toBe(false); } crewConfigAccount = await readFromRPCOrError( provider.connection, program, crewConfigPubkey, CrewConfig, ); expect(crewConfigAccount.data.totalAllocated).toEqual( (inputQty + inputQty0) * redemptionAmount, ); }); it('User can mint a cnft', async () => { const dataHash: FixedSizeArray = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, ]; const ix = mintCrewMember( program, mintCrewCardsManagerKey, crewProfile.publicKey(), user.publicKey(), user.publicKey(), userRedemptionPubkey, packTypePubkey, packTiersPubkey, toWeb3JsPublicKey(collectionMint.publicKey), toWeb3JsPublicKey(umi.identity.publicKey), merkleTrees[0], SERVER_PREIMAGE_KEY, { userRedemptionIndex: 1, keyIndex: MINT_CREW_CARDS, dataHash, }, ); const mintRes = await buildSendAndCheck(ix, funder, provider.connection, { sendOptions: { skipPreflight: true }, suppressLogging: true, }); const userRedemptionAccount = await readFromRPCOrError( provider.connection, program, userRedemptionPubkey, UserRedemption, ); expect(userRedemptionAccount.mintBits.get(1)).toBe(true); expect(userRedemptionAccount.data.numberMinted).toEqual(1); const crewConfigAccount = await readFromRPCOrError( provider.connection, program, crewConfigPubkey, CrewConfig, ); expect(crewConfigAccount.data.totalMinted).toBe(1); const txn = await connection.getTransaction(mintRes, { maxSupportedTransactionVersion: 1, commitment: 'confirmed', }); txn?.transaction.message.getAccountKeys({ addressLookupTableAccounts: lookupTables, }); if (txn === null || txn.meta === null || !txn.meta.innerInstructions) { throw new Error('Transaction not found'); } const inner = txn.meta.innerInstructions[0]; const bubblegumInner = inner.instructions[0]; const data = base58.decode(bubblegumInner.data); const ixData = getMintToCollectionV1InstructionDataSerializer().deserialize(data)[0]; const uriData = decodeUri(ixData.metadata.uri, CREW_URI_PREFIX.length); expect(uriData.dataHash).toEqual(dataHash); expect(uriData.mintIndex).toEqual(redemptionAmount * inputQty0 + 1); expect(uriData.quickData.rarity).toEqual(Rarity.Legendary); expect(uriData.quickData.minor_aptitudes.aptitudes()).toEqual([]); expect(uriData.quickData.anomalous_aptitudes.aptitudes()).toEqual([]); expect(uriData.quickData.major_aptitudes.aptitudes().length).toEqual(3); }); it('rejects invalid preimages during minting', async () => { const dataHash: FixedSizeArray = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, ]; const badPreimageKey = Keypair.generate().publicKey; const ix = mintCrewMember( program, mintCrewCardsManagerKey, crewProfile.publicKey(), user.publicKey(), user.publicKey(), userRedemptionPubkey, packTypePubkey, packTiersPubkey, toWeb3JsPublicKey(collectionMint.publicKey), toWeb3JsPublicKey(umi.identity.publicKey), merkleTrees[0], badPreimageKey, { userRedemptionIndex: 0, keyIndex: MINT_CREW_CARDS, dataHash, }, ); await expect( buildSendAndCheck(ix, funder, provider.connection, { sendOptions: { skipPreflight: true }, suppressLogging: true, }), ).rejects.toThrow( new RegExp(`"Custom": ${crewErrorMap.HashMismatch.code}`), ); }); it('Can mint out and auto close the user redemption account', async () => { const mintIx = (index: number) => mintCrewMember( program, mintCrewCardsManagerKey, crewProfile.publicKey(), user.publicKey(), user.publicKey(), userRedemptionPubkey, packTypePubkey, packTiersPubkey, toWeb3JsPublicKey(collectionMint.publicKey), toWeb3JsPublicKey(umi.identity.publicKey), merkleTrees[0], SERVER_PREIMAGE_KEY, { userRedemptionIndex: index, keyIndex: MINT_CREW_CARDS, dataHash: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], }, ); const ixs = [ mintIx(0), mintIx(2), mintIx(3), mintIx(4), mintIx(5), mintIx(6), mintIx(7), mintIx(8), mintIx(9), ]; await buildSendAndCheck( ixs, funder, provider.connection, { sendOptions: { skipPreflight: true }, suppressLogging: true, }, lookupTables, ); const userRedemptionAccount = await readFromRPCNullable( provider.connection, program, userRedemptionPubkey, UserRedemption, ); expect(userRedemptionAccount).toBeNull(); const crewConfigAccount = await readFromRPCOrError( provider.connection, program, crewConfigPubkey, CrewConfig, ); expect(crewConfigAccount.data.totalMinted).toEqual(redemptionAmount); }); it('accesses the correct bit with BitViewer', () => { const buffer = Buffer.from([0, 1, 2, 128]); const viewer = new BitViewer(buffer); const expected = [ false, false, false, false, false, false, false, false, // full byte true, false, false, false, false, false, false, false, // full byte false, true, false, false, false, false, false, false, // full byte false, false, false, false, false, false, false, true, // full byte ]; for (let i = 0; i < buffer.length * 8; i++) { expect(viewer.get(i)).toBe(expected[i]); } const newBuffer = Buffer.from([255, 1, 128]); const newViewer = new BitViewer(newBuffer); // first byte and first bit of second byte are set expect(newViewer.firstUnsetIndex()).toBe(9); }); });