diff --git a/clients/js/src/generated/errors/mplCore.ts b/clients/js/src/generated/errors/mplCore.ts index a70010f3..f830af2e 100644 --- a/clients/js/src/generated/errors/mplCore.ts +++ b/clients/js/src/generated/errors/mplCore.ts @@ -608,6 +608,19 @@ export class InvalidPluginOperationError extends ProgramError { codeToErrorMap.set(0x29, InvalidPluginOperationError); nameToErrorMap.set('InvalidPluginOperation', InvalidPluginOperationError); +/** CollectionMustBeEmpty: Collection must be empty to be burned */ +export class CollectionMustBeEmptyError extends ProgramError { + override readonly name: string = 'CollectionMustBeEmpty'; + + readonly code: number = 0x2a; // 42 + + constructor(program: Program, cause?: Error) { + super('Collection must be empty to be burned', program, cause); + } +} +codeToErrorMap.set(0x2a, CollectionMustBeEmptyError); +nameToErrorMap.set('CollectionMustBeEmpty', CollectionMustBeEmptyError); + /** * Attempts to resolve a custom program error from the provided error code. * @category Errors diff --git a/clients/js/test/burn.test.ts b/clients/js/test/burn.test.ts index 89c4dd89..0af4fd21 100644 --- a/clients/js/test/burn.test.ts +++ b/clients/js/test/burn.test.ts @@ -2,7 +2,7 @@ import { generateSigner, sol } from '@metaplex-foundation/umi'; import test from 'ava'; import { generateSignerWithSol } from '@metaplex-foundation/umi-bundle-tests'; -import { burnCollectionV1, burnV1, pluginAuthorityPair } from '../src'; +import { burnV1, pluginAuthorityPair } from '../src'; import { DEFAULT_ASSET, DEFAULT_COLLECTION, @@ -60,6 +60,32 @@ test('it cannot burn an asset if not the owner', async (t) => { }); }); +test('it cannot burn an asset as the authority', async (t) => { + const umi = await createUmi(); + const authority = generateSigner(umi); + + const asset = await createAsset(umi, { updateAuthority: authority }); + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: umi.identity.publicKey, + updateAuthority: { type: 'Address', address: authority.publicKey }, + }); + + const result = burnV1(umi, { + asset: asset.publicKey, + authority, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'NoApprovals' }); + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: umi.identity.publicKey, + updateAuthority: { type: 'Address', address: authority.publicKey }, + }); +}); + test('it cannot burn an asset if it is frozen', async (t) => { const umi = await createUmi(); @@ -208,25 +234,6 @@ test('it cannot use an invalid noop program for assets', async (t) => { await t.throwsAsync(result, { name: 'InvalidLogWrapperProgram' }); }); -test('it cannot use an invalid noop program for collections', async (t) => { - const umi = await createUmi(); - const collection = await createCollection(umi); - const fakeLogWrapper = generateSigner(umi); - await assertCollection(t, umi, { - ...DEFAULT_COLLECTION, - collection: collection.publicKey, - updateAuthority: umi.identity.publicKey, - }); - - const result = burnCollectionV1(umi, { - collection: collection.publicKey, - logWrapper: fakeLogWrapper.publicKey, - compressionProof: null, - }).sendAndConfirm(umi); - - await t.throwsAsync(result, { name: 'InvalidLogWrapperProgram' }); -}); - test('it can burn using owner authority', async (t) => { const umi = await createUmi(); const owner = await generateSignerWithSol(umi); diff --git a/clients/js/test/burnCollection.test.ts b/clients/js/test/burnCollection.test.ts new file mode 100644 index 00000000..abafbe0e --- /dev/null +++ b/clients/js/test/burnCollection.test.ts @@ -0,0 +1,130 @@ +import { generateSigner, sol } from '@metaplex-foundation/umi'; +import test from 'ava'; + +import { generateSignerWithSol } from '@metaplex-foundation/umi-bundle-tests'; +import { burnCollection } from '../src'; +import { + DEFAULT_COLLECTION, + assertBurned, + assertCollection, + createAssetWithCollection, + createCollection, + createUmi, +} from './_setupRaw'; + +test('it can burn a collection as the authority', async (t) => { + const umi = await createUmi(); + const collection = await createCollection(umi); + await assertCollection(t, umi, { + ...DEFAULT_COLLECTION, + collection: collection.publicKey, + updateAuthority: umi.identity.publicKey, + }); + + await burnCollection(umi, { + collection: collection.publicKey, + compressionProof: null, + }).sendAndConfirm(umi); + + // And the asset address still exists but was resized to 1. + const afterCollection = await assertBurned(t, umi, collection.publicKey); + t.deepEqual(afterCollection.lamports, sol(0.00089784)); +}); + +test('it cannot burn a collection if not the authority', async (t) => { + const umi = await createUmi(); + const attacker = generateSigner(umi); + + const collection = await createCollection(umi); + await assertCollection(t, umi, { + ...DEFAULT_COLLECTION, + collection: collection.publicKey, + updateAuthority: umi.identity.publicKey, + }); + + const result = burnCollection(umi, { + collection: collection.publicKey, + authority: attacker, + compressionProof: null, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + await assertCollection(t, umi, { + ...DEFAULT_COLLECTION, + collection: collection.publicKey, + updateAuthority: umi.identity.publicKey, + }); +}); + +test('it cannot burn a collection if it has Assets in it', async (t) => { + const umi = await createUmi(); + + const { collection } = await createAssetWithCollection(umi, {}); + + await assertCollection(t, umi, { + ...DEFAULT_COLLECTION, + collection: collection.publicKey, + updateAuthority: umi.identity.publicKey, + }); + + const result = burnCollection(umi, { + collection: collection.publicKey, + compressionProof: null, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'CollectionMustBeEmpty' }); + await assertCollection(t, umi, { + ...DEFAULT_COLLECTION, + collection: collection.publicKey, + updateAuthority: umi.identity.publicKey, + }); +}); + +test('it can burn asset with different payer', async (t) => { + const umi = await createUmi(); + const authority = await generateSignerWithSol(umi); + const collection = await createCollection(umi, { + updateAuthority: authority.publicKey, + }); + await assertCollection(t, umi, { + ...DEFAULT_COLLECTION, + collection: collection.publicKey, + updateAuthority: authority.publicKey, + }); + + const lamportsBefore = await umi.rpc.getBalance(umi.identity.publicKey); + + await burnCollection(umi, { + collection: collection.publicKey, + payer: umi.identity, + authority, + compressionProof: null, + }).sendAndConfirm(umi); + + // And the asset address still exists but was resized to 1. + const afterCollection = await assertBurned(t, umi, collection.publicKey); + t.deepEqual(afterCollection.lamports, sol(0.00089784)); + + const lamportsAfter = await umi.rpc.getBalance(umi.identity.publicKey); + + t.true(lamportsAfter.basisPoints > lamportsBefore.basisPoints); +}); + +test('it cannot use an invalid noop program for collections', async (t) => { + const umi = await createUmi(); + const collection = await createCollection(umi); + const fakeLogWrapper = generateSigner(umi); + await assertCollection(t, umi, { + ...DEFAULT_COLLECTION, + collection: collection.publicKey, + updateAuthority: umi.identity.publicKey, + }); + + const result = burnCollection(umi, { + collection: collection.publicKey, + logWrapper: fakeLogWrapper.publicKey, + compressionProof: null, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidLogWrapperProgram' }); +}); diff --git a/clients/rust/src/generated/errors/mpl_core.rs b/clients/rust/src/generated/errors/mpl_core.rs index e7ef105f..232dd7f1 100644 --- a/clients/rust/src/generated/errors/mpl_core.rs +++ b/clients/rust/src/generated/errors/mpl_core.rs @@ -136,6 +136,9 @@ pub enum MplCoreError { /// 41 (0x29) - Invalid plugin operation #[error("Invalid plugin operation")] InvalidPluginOperation, + /// 42 (0x2A) - Collection must be empty to be burned + #[error("Collection must be empty to be burned")] + CollectionMustBeEmpty, } impl solana_program::program_error::PrintProgramError for MplCoreError { diff --git a/idls/mpl_core.json b/idls/mpl_core.json index acc00a06..ea9a1e4d 100644 --- a/idls/mpl_core.json +++ b/idls/mpl_core.json @@ -4325,6 +4325,11 @@ "code": 41, "name": "InvalidPluginOperation", "msg": "Invalid plugin operation" + }, + { + "code": 42, + "name": "CollectionMustBeEmpty", + "msg": "Collection must be empty to be burned" } ], "metadata": { diff --git a/programs/mpl-core/src/error.rs b/programs/mpl-core/src/error.rs index 59b19a1f..1c95d35b 100644 --- a/programs/mpl-core/src/error.rs +++ b/programs/mpl-core/src/error.rs @@ -176,6 +176,10 @@ pub enum MplCoreError { /// 41 - Invalid plugin operation #[error("Invalid plugin operation")] InvalidPluginOperation, + + /// 42 - Collection must be empty to be burned + #[error("Collection must be empty to be burned")] + CollectionMustBeEmpty, } impl PrintProgramError for MplCoreError { diff --git a/programs/mpl-core/src/processor/burn.rs b/programs/mpl-core/src/processor/burn.rs index 5948bead..98dae2c1 100644 --- a/programs/mpl-core/src/processor/burn.rs +++ b/programs/mpl-core/src/processor/burn.rs @@ -131,20 +131,15 @@ pub(crate) fn burn_collection<'a>( } } - // Validate collection permissions. - let _ = validate_collection_permissions( - accounts, - authority, - ctx.accounts.collection, - None, - None, - CollectionV1::check_burn, - PluginType::check_burn, - CollectionV1::validate_burn, - Plugin::validate_burn, - Some(ExternalPluginAdapter::validate_burn), - Some(HookableLifecycleEvent::Burn), - )?; + let collection = CollectionV1::load(ctx.accounts.collection, 0)?; + if collection.current_size > 0 { + return Err(MplCoreError::CollectionMustBeEmpty.into()); + } + + // If the update authority is the one burning the collection, and the collection is empty, then it can be burned. + if authority.key != &collection.update_authority { + return Err(MplCoreError::InvalidAuthority.into()); + } process_burn(ctx.accounts.collection, ctx.accounts.payer) } diff --git a/programs/mpl-core/src/state/collection.rs b/programs/mpl-core/src/state/collection.rs index 1b4767fd..c4056576 100644 --- a/programs/mpl-core/src/state/collection.rs +++ b/programs/mpl-core/src/state/collection.rs @@ -220,7 +220,7 @@ impl CollectionV1 { /// Validate the burn lifecycle event. pub fn validate_burn( &self, - _authority_info: &AccountInfo, + _: &AccountInfo, _: Option<&Plugin>, _: Option<&ExternalPluginAdapter>, ) -> Result {