diff --git a/clients/js/test/_setup.ts b/clients/js/test/_setup.ts index fb8972c7..f5a0d4cb 100644 --- a/clients/js/test/_setup.ts +++ b/clients/js/test/_setup.ts @@ -1,10 +1,12 @@ /* eslint-disable import/no-extraneous-dependencies */ import { + Context, generateSigner, percentAmount, publicKey, PublicKey, Signer, + TransactionBuilder, transactionBuilder, Umi, } from '@metaplex-foundation/umi'; @@ -17,6 +19,19 @@ import { TokenStandard, verifyCreatorV1, } from '../src'; +import { + BurnTokenInstructionAccounts, + BurnTokenInstructionArgs, + BurnTokenInstructionDataArgs, + getAccountMetasAndSigners, + getBurnTokenInstructionDataSerializer, + getSetAuthorityInstructionDataSerializer, + ResolvedAccount, + ResolvedAccountsWithIndices, + SetAuthorityInstructionAccounts, + SetAuthorityInstructionArgs, + SetAuthorityInstructionDataArgs, +} from '@metaplex-foundation/mpl-toolbox'; export type TokenStandardKeys = keyof typeof TokenStandard; @@ -142,3 +157,91 @@ export const createDigitalAssetWithVerifiedCreators = async ( return mint; }; + +export function burnToken22( + context: Pick, + input: BurnTokenInstructionAccounts & BurnTokenInstructionArgs +): TransactionBuilder { + // Program ID. + const programId = SPL_TOKEN_2022_PROGRAM_ID; + + // Accounts. + const resolvedAccounts: ResolvedAccountsWithIndices = { + account: { index: 0, isWritable: true, value: input.account ?? null }, + mint: { index: 1, isWritable: true, value: input.mint ?? null }, + authority: { index: 2, isWritable: false, value: input.authority ?? null }, + }; + + // Arguments. + const resolvedArgs: BurnTokenInstructionArgs = { ...input }; + + // Default values. + if (!resolvedAccounts.authority.value) { + resolvedAccounts.authority.value = context.identity; + } + + // Accounts in order. + const orderedAccounts: ResolvedAccount[] = Object.values( + resolvedAccounts + ).sort((a, b) => a.index - b.index); + + // Keys and Signers. + const [keys, signers] = getAccountMetasAndSigners( + orderedAccounts, + 'programId', + programId + ); + + // Data. + const data = getBurnTokenInstructionDataSerializer().serialize( + resolvedArgs as BurnTokenInstructionDataArgs + ); + + // Bytes Created On Chain. + const bytesCreatedOnChain = 0; + + return transactionBuilder([ + { instruction: { keys, programId, data }, signers, bytesCreatedOnChain }, + ]); +} + +export function setAuthority22( + context: Pick, + input: SetAuthorityInstructionAccounts & SetAuthorityInstructionArgs +): TransactionBuilder { + // Program ID. + const programId = SPL_TOKEN_2022_PROGRAM_ID; + + // Accounts. + const resolvedAccounts: ResolvedAccountsWithIndices = { + owned: { index: 0, isWritable: true, value: input.owned ?? null }, + owner: { index: 1, isWritable: false, value: input.owner ?? null }, + }; + + // Arguments. + const resolvedArgs: SetAuthorityInstructionArgs = { ...input }; + + // Accounts in order. + const orderedAccounts: ResolvedAccount[] = Object.values( + resolvedAccounts + ).sort((a, b) => a.index - b.index); + + // Keys and Signers. + const [keys, signers] = getAccountMetasAndSigners( + orderedAccounts, + 'programId', + programId + ); + + // Data. + const data = getSetAuthorityInstructionDataSerializer().serialize( + resolvedArgs as SetAuthorityInstructionDataArgs + ); + + // Bytes Created On Chain. + const bytesCreatedOnChain = 0; + + return transactionBuilder([ + { instruction: { keys, programId, data }, signers, bytesCreatedOnChain }, + ]); +} diff --git a/clients/js/test/close/fungible.test.ts b/clients/js/test/close/fungible.test.ts index 77a42417..c643a8ee 100644 --- a/clients/js/test/close/fungible.test.ts +++ b/clients/js/test/close/fungible.test.ts @@ -4,6 +4,7 @@ import { generateSigner, lamports, MaybeRpcAccount, + percentAmount, publicKey, subtractAmounts, } from '@metaplex-foundation/umi'; @@ -11,13 +12,23 @@ import { readFileSync } from 'fs'; import { AuthorityType, burnToken, + findAssociatedTokenPda, setAuthority, SPL_SYSTEM_PROGRAM_ID, } from '@metaplex-foundation/mpl-toolbox'; -import { createDigitalAssetWithToken, createUmi } from '../_setup'; +import { + burnToken22, + createDigitalAssetWithToken, + createUmi, + setAuthority22, + SPL_TOKEN_2022_PROGRAM_ID, +} from '../_setup'; import { closeAccounts, + createV1, + fetchDigitalAsset, fetchDigitalAssetWithAssociatedToken, + mintV1, TokenStandard, } from '../../src'; @@ -87,6 +98,90 @@ test.skip('it can close ownerless metadata for a fungible with zero supply and n t.deepEqual(subtractAmounts(lamportsAfter, lamportsBefore), metadataLamports); }); +test.skip('it can close ownerless metadata for a t22 fungible with zero supply and no mint authority', async (t) => { + const umi = await createUmi(); + const mint = generateSigner(umi); + const closeAuthority = createSignerFromKeypair( + umi, + umi.eddsa.createKeypairFromSecretKey( + new Uint8Array( + JSON.parse( + readFileSync( + '/Users/kelliott/Metaplex/keys/C1oseLQExhuEzeBhsVbLtseSpVgvpHDbBj3PTevBCEBh.json' + ).toString() + ) + ) + ) + ); + + // const mint = await createDigitalAssetWithToken(umi, { + // name: 'Fungible', + // symbol: 'FUN', + // uri: 'https://example.com/nft.json', + // tokenStandard: TokenStandard.Fungible, + // amount: 1, + // }); + await createV1(umi, { + mint, + name: 'Fungible', + symbol: 'FUN', + uri: 'https://example.com/nft.json', + sellerFeeBasisPoints: percentAmount(0), + splTokenProgram: SPL_TOKEN_2022_PROGRAM_ID, + tokenStandard: TokenStandard.Fungible, + }).sendAndConfirm(umi); + + // And we derive the associated token account from SPL Token 2022. + const token = findAssociatedTokenPda(umi, { + mint: mint.publicKey, + owner: umi.identity.publicKey, + tokenProgramId: SPL_TOKEN_2022_PROGRAM_ID, + }); + + await mintV1(umi, { + mint: mint.publicKey, + token, + tokenOwner: umi.identity.publicKey, + amount: 1, + tokenStandard: TokenStandard.Fungible, + splTokenProgram: SPL_TOKEN_2022_PROGRAM_ID, + }).sendAndConfirm(umi); + + const asset = await fetchDigitalAsset(umi, mint.publicKey); + + await burnToken22(umi, { + account: token, + mint: mint.publicKey, + amount: 1, + }).sendAndConfirm(umi); + + await setAuthority22(umi, { + owned: mint.publicKey, + owner: umi.identity, + authorityType: AuthorityType.MintTokens, + newAuthority: null, + }).sendAndConfirm(umi); + + const metadataLamports = await umi.rpc.getBalance(asset.metadata.publicKey); + const lamportsBefore = await umi.rpc.getBalance(closeDestination); + await closeAccounts(umi, { + mint: mint.publicKey, + authority: closeAuthority, + destination: closeDestination, + }).sendAndConfirm(umi); + + t.deepEqual(await umi.rpc.getAccount(asset.metadata.publicKey), < + MaybeRpcAccount + >{ + publicKey: asset.metadata.publicKey, + exists: false, + }); + t.deepEqual(await umi.rpc.getBalance(asset.metadata.publicKey), lamports(0)); + + const lamportsAfter = await umi.rpc.getBalance(closeDestination); + t.deepEqual(subtractAmounts(lamportsAfter, lamportsBefore), metadataLamports); +}); + test.skip('it can close ownerless metadata for a fungible with zero supply and mint authority set to the system program', async (t) => { const umi = await createUmi(); const closeAuthority = createSignerFromKeypair( diff --git a/clients/js/test/close/nonFungible.test.ts b/clients/js/test/close/nonFungible.test.ts index 2afdc2cd..01f64492 100644 --- a/clients/js/test/close/nonFungible.test.ts +++ b/clients/js/test/close/nonFungible.test.ts @@ -5,15 +5,27 @@ import { generateSigner, lamports, MaybeRpcAccount, + percentAmount, publicKey, subtractAmounts, } from '@metaplex-foundation/umi'; import { readFileSync } from 'fs'; -import { burnToken } from '@metaplex-foundation/mpl-toolbox'; -import { createDigitalAssetWithToken, createUmi } from '../_setup'; +import { + burnToken, + findAssociatedTokenPda, +} from '@metaplex-foundation/mpl-toolbox'; +import { + burnToken22, + createDigitalAssetWithToken, + createUmi, + SPL_TOKEN_2022_PROGRAM_ID, +} from '../_setup'; import { closeAccounts, + createV1, + fetchDigitalAsset, fetchDigitalAssetWithAssociatedToken, + mintV1, printSupply, printV1, TokenStandard, @@ -23,6 +35,93 @@ const closeDestination = publicKey( 'GxCXYtrnaU6JXeAza8Ugn4EE6QiFinpfn8t3Lo4UkBDX' ); +test.skip('it can close t22 ownerless metadata for a non-fungible with zero supply', async (t) => { + const umi = await createUmi(); + const mint = generateSigner(umi); + const closeAuthority = createSignerFromKeypair( + umi, + umi.eddsa.createKeypairFromSecretKey( + new Uint8Array( + JSON.parse( + readFileSync( + '/Users/kelliott/Metaplex/keys/C1oseLQExhuEzeBhsVbLtseSpVgvpHDbBj3PTevBCEBh.json' + ).toString() + ) + ) + ) + ); + + await createV1(umi, { + mint, + name: 'My NFT', + uri: 'https://example.com/my-nft.json', + tokenStandard: TokenStandard.NonFungible, + splTokenProgram: SPL_TOKEN_2022_PROGRAM_ID, + sellerFeeBasisPoints: percentAmount(0), + }).sendAndConfirm(umi); + + // And we derive the associated token account from SPL Token 2022. + const token = findAssociatedTokenPda(umi, { + mint: mint.publicKey, + owner: umi.identity.publicKey, + tokenProgramId: SPL_TOKEN_2022_PROGRAM_ID, + }); + + // When we mint one token. + await mintV1(umi, { + mint: mint.publicKey, + token, + tokenOwner: umi.identity.publicKey, + amount: 1, + splTokenProgram: SPL_TOKEN_2022_PROGRAM_ID, + tokenStandard: TokenStandard.NonFungible, + }).sendAndConfirm(umi, { send: { skipPreflight: true } }); + + const asset = await fetchDigitalAsset(umi, mint.publicKey); + + await burnToken22(umi, { + account: token, + mint: mint.publicKey, + amount: 1, + }).sendAndConfirm(umi); + + const metadataLamports = await umi.rpc.getBalance(asset.metadata.publicKey); + + if (asset.edition === undefined) { + t.fail('Expected edition to exist'); + } + + const masterEditionLamports = await umi.rpc.getBalance( + asset.edition!.publicKey + ); + const lamportsBefore = await umi.rpc.getBalance(closeDestination); + await closeAccounts(umi, { + mint: mint.publicKey, + authority: closeAuthority, + destination: closeDestination, + }).sendAndConfirm(umi); + + t.deepEqual(await umi.rpc.getAccount(asset.metadata.publicKey), < + MaybeRpcAccount + >{ + publicKey: asset.metadata.publicKey, + exists: false, + }); + t.deepEqual(await umi.rpc.getAccount(asset.edition!.publicKey), < + MaybeRpcAccount + >{ + publicKey: asset.edition!.publicKey, + exists: false, + }); + t.deepEqual(await umi.rpc.getBalance(asset.metadata.publicKey), lamports(0)); + + const lamportsAfter = await umi.rpc.getBalance(closeDestination); + t.deepEqual( + subtractAmounts(lamportsAfter, lamportsBefore), + addAmounts(metadataLamports, masterEditionLamports) + ); +}); + test.skip('it can close ownerless metadata for a non-fungible with zero supply', async (t) => { const umi = await createUmi(); const closeAuthority = createSignerFromKeypair( @@ -65,8 +164,7 @@ test.skip('it can close ownerless metadata for a non-fungible with zero supply', } const masterEditionLamports = await umi.rpc.getBalance( - // @ts-ignore - asset.edition.publicKey + asset.edition!.publicKey ); const lamportsBefore = await umi.rpc.getBalance(closeDestination); await closeAccounts(umi, { @@ -257,8 +355,8 @@ test.skip('it can close ownerless metadata for a non-fungible edition with zero if (asset.edition === undefined) { t.fail('Expected edition to exist'); } - // @ts-ignore - const editionLamports = await umi.rpc.getBalance(asset.edition.publicKey); + + const editionLamports = await umi.rpc.getBalance(asset.edition!.publicKey); const lamportsBefore = await umi.rpc.getBalance(closeDestination); await closeAccounts(umi, { mint: editionMint.publicKey, @@ -281,6 +379,111 @@ test.skip('it can close ownerless metadata for a non-fungible edition with zero ); }); +test.skip('it can close ownerless metadata for a t22 non-fungible edition with zero supply', async (t) => { + const umi = await createUmi(); + const originalMint = generateSigner(umi); + const closeAuthority = createSignerFromKeypair( + umi, + umi.eddsa.createKeypairFromSecretKey( + new Uint8Array( + JSON.parse( + readFileSync( + '/Users/kelliott/Metaplex/keys/C1oseLQExhuEzeBhsVbLtseSpVgvpHDbBj3PTevBCEBh.json' + ).toString() + ) + ) + ) + ); + + await createV1(umi, { + mint: originalMint, + name: 'My NFT', + symbol: 'MNFT', + uri: 'https://example.com/nft.json', + printSupply: printSupply('Limited', [10]), + tokenStandard: TokenStandard.NonFungible, + splTokenProgram: SPL_TOKEN_2022_PROGRAM_ID, + sellerFeeBasisPoints: percentAmount(0), + }).sendAndConfirm(umi); + + const originalToken = findAssociatedTokenPda(umi, { + mint: originalMint.publicKey, + owner: umi.identity.publicKey, + tokenProgramId: SPL_TOKEN_2022_PROGRAM_ID, + }); + + await mintV1(umi, { + mint: originalMint.publicKey, + token: originalToken, + tokenOwner: umi.identity.publicKey, + amount: 1, + splTokenProgram: SPL_TOKEN_2022_PROGRAM_ID, + tokenStandard: TokenStandard.NonFungible, + }).sendAndConfirm(umi); + + // When we print a new edition of the asset. + const editionMint = generateSigner(umi); + const token = findAssociatedTokenPda(umi, { + mint: editionMint.publicKey, + owner: umi.identity.publicKey, + tokenProgramId: SPL_TOKEN_2022_PROGRAM_ID, + }); + + await printV1(umi, { + masterTokenAccountOwner: umi.identity, + masterEditionMint: originalMint.publicKey, + masterTokenAccount: originalToken, + editionMint, + editionTokenAccountOwner: umi.identity.publicKey, + editionTokenAccount: token, + editionNumber: 1, + tokenStandard: TokenStandard.NonFungible, + splTokenProgram: SPL_TOKEN_2022_PROGRAM_ID, + }).sendAndConfirm(umi); + + const asset = await fetchDigitalAsset(umi, editionMint.publicKey); + + await burnToken22(umi, { + account: token, + mint: editionMint.publicKey, + amount: 1, + }).sendAndConfirm(umi); + + const metadataLamports = await umi.rpc.getBalance(asset.metadata.publicKey); + + if (asset.edition === undefined) { + t.fail('Expected edition to exist'); + } + + const editionLamports = await umi.rpc.getBalance(asset.edition!.publicKey); + const lamportsBefore = await umi.rpc.getBalance(closeDestination); + await closeAccounts(umi, { + mint: editionMint.publicKey, + authority: closeAuthority, + destination: closeDestination, + }).sendAndConfirm(umi); + + t.deepEqual(await umi.rpc.getAccount(asset.metadata.publicKey), < + MaybeRpcAccount + >{ + publicKey: asset.metadata.publicKey, + exists: false, + }); + t.deepEqual(await umi.rpc.getAccount(asset.edition!.publicKey), < + MaybeRpcAccount + >{ + publicKey: asset.edition!.publicKey, + exists: false, + }); + t.deepEqual(await umi.rpc.getBalance(asset.metadata.publicKey), lamports(0)); + + const lamportsAfter = await umi.rpc.getBalance(closeDestination); + t.deepEqual( + subtractAmounts(lamportsAfter, lamportsBefore), + addAmounts(metadataLamports, editionLamports) + ); +}); + test.skip('it cannot close ownerless metadata for a non-fungible edition with non-zero supply', async (t) => { const umi = await createUmi(); const closeAuthority = createSignerFromKeypair( diff --git a/programs/token-metadata/program/src/processor/close/mod.rs b/programs/token-metadata/program/src/processor/close/mod.rs index 6f83177a..f7d4e30c 100644 --- a/programs/token-metadata/program/src/processor/close/mod.rs +++ b/programs/token-metadata/program/src/processor/close/mod.rs @@ -1,6 +1,6 @@ use mpl_utils::{assert_signer, close_account_raw, token::SPL_TOKEN_PROGRAM_IDS}; -use solana_program::{program_option::COption, program_pack::Pack, system_program}; -use spl_token_2022::state::Mint; +use solana_program::{program_option::COption, system_program}; +use spl_token_2022::{extension::StateWithExtensions, state::Mint}; use crate::{ assertions::assert_owner_in, @@ -58,7 +58,7 @@ pub(crate) fn process_close_accounts<'a>( let mint = if mint_closed { None } else { - Some(Mint::unpack(&ctx.accounts.mint_info.data.borrow())?) + Some(StateWithExtensions::::unpack(&ctx.accounts.mint_info.data.borrow())?.base) }; // Mint supply must be zero. if mint_closed diff --git a/programs/token-metadata/program/src/processor/mod.rs b/programs/token-metadata/program/src/processor/mod.rs index 7f110095..c1fc7520 100644 --- a/programs/token-metadata/program/src/processor/mod.rs +++ b/programs/token-metadata/program/src/processor/mod.rs @@ -168,7 +168,10 @@ pub fn process_instruction<'a>( msg!("IX: Resize"); resize::process_resize(program_id, accounts) } - MetadataInstruction::CloseAccounts => close::process_close_accounts(program_id, accounts), + MetadataInstruction::CloseAccounts => { + msg!("IX: Close Accounts"); + close::process_close_accounts(program_id, accounts) + } _ => { // pNFT accounts and SPL Token-2022 program can only be used by the "new" API; before // forwarding the transaction to the "legacy" processor we determine whether we are