diff --git a/clients/js/README.md b/clients/js/README.md index 9e5a0498..b22cb63c 100644 --- a/clients/js/README.md +++ b/clients/js/README.md @@ -11,7 +11,7 @@ A Umi-compatible JavaScript library for the project. ``` 3. Finally, register the library with your Umi instance like so. ```ts - import { createUmi } from '@metaplex-foundation/umi'; + import { createUmi } from '@metaplex-foundation/umi-bundle-defaults'; import { mplCore } from '@metaplex-foundation/mpl-core'; const umi = createUmi(''); diff --git a/clients/js/src/generated/errors/mplCore.ts b/clients/js/src/generated/errors/mplCore.ts index b3cb89c2..0c7414a4 100644 --- a/clients/js/src/generated/errors/mplCore.ts +++ b/clients/js/src/generated/errors/mplCore.ts @@ -604,56 +604,69 @@ 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); + /** TwoDataSources: Two data sources provided, only one is allowed */ export class TwoDataSourcesError extends ProgramError { override readonly name: string = 'TwoDataSources'; - readonly code: number = 0x2a; // 42 + readonly code: number = 0x2b; // 43 constructor(program: Program, cause?: Error) { super('Two data sources provided, only one is allowed', program, cause); } } -codeToErrorMap.set(0x2a, TwoDataSourcesError); +codeToErrorMap.set(0x2b, TwoDataSourcesError); nameToErrorMap.set('TwoDataSources', TwoDataSourcesError); /** UnsupportedOperation: External Plugin does not support this operation */ export class UnsupportedOperationError extends ProgramError { override readonly name: string = 'UnsupportedOperation'; - readonly code: number = 0x2b; // 43 + readonly code: number = 0x2c; // 44 constructor(program: Program, cause?: Error) { super('External Plugin does not support this operation', program, cause); } } -codeToErrorMap.set(0x2b, UnsupportedOperationError); +codeToErrorMap.set(0x2c, UnsupportedOperationError); nameToErrorMap.set('UnsupportedOperation', UnsupportedOperationError); /** NoDataSources: No data sources provided, one is required */ export class NoDataSourcesError extends ProgramError { override readonly name: string = 'NoDataSources'; - readonly code: number = 0x2c; // 44 + readonly code: number = 0x2d; // 45 constructor(program: Program, cause?: Error) { super('No data sources provided, one is required', program, cause); } } -codeToErrorMap.set(0x2c, NoDataSourcesError); +codeToErrorMap.set(0x2d, NoDataSourcesError); nameToErrorMap.set('NoDataSources', NoDataSourcesError); /** InvalidPluginAdapterTarget: This plugin adapter cannot be added to an Asset */ export class InvalidPluginAdapterTargetError extends ProgramError { override readonly name: string = 'InvalidPluginAdapterTarget'; - readonly code: number = 0x2d; // 45 + readonly code: number = 0x2e; // 46 constructor(program: Program, cause?: Error) { super('This plugin adapter cannot be added to an Asset', program, cause); } } -codeToErrorMap.set(0x2d, InvalidPluginAdapterTargetError); +codeToErrorMap.set(0x2e, InvalidPluginAdapterTargetError); nameToErrorMap.set( 'InvalidPluginAdapterTarget', InvalidPluginAdapterTargetError @@ -663,7 +676,7 @@ nameToErrorMap.set( export class CannotAddDataSectionError extends ProgramError { override readonly name: string = 'CannotAddDataSection'; - readonly code: number = 0x2e; // 46 + readonly code: number = 0x2f; // 47 constructor(program: Program, cause?: Error) { super( @@ -673,7 +686,7 @@ export class CannotAddDataSectionError extends ProgramError { ); } } -codeToErrorMap.set(0x2e, CannotAddDataSectionError); +codeToErrorMap.set(0x2f, CannotAddDataSectionError); nameToErrorMap.set('CannotAddDataSection', CannotAddDataSectionError); /** 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 af45fae9..93e72b13 100644 --- a/clients/rust/src/generated/errors/mpl_core.rs +++ b/clients/rust/src/generated/errors/mpl_core.rs @@ -136,19 +136,22 @@ pub enum MplCoreError { /// 41 (0x29) - Invalid plugin operation #[error("Invalid plugin operation")] InvalidPluginOperation, - /// 42 (0x2A) - Two data sources provided, only one is allowed + /// 42 (0x2A) - Collection must be empty to be burned + #[error("Collection must be empty to be burned")] + CollectionMustBeEmpty, + /// 43 (0x2B) - Two data sources provided, only one is allowed #[error("Two data sources provided, only one is allowed")] TwoDataSources, - /// 43 (0x2B) - External Plugin does not support this operation + /// 44 (0x2C) - External Plugin does not support this operation #[error("External Plugin does not support this operation")] UnsupportedOperation, - /// 44 (0x2C) - No data sources provided, one is required + /// 45 (0x2D) - No data sources provided, one is required #[error("No data sources provided, one is required")] NoDataSources, - /// 45 (0x2D) - This plugin adapter cannot be added to an Asset + /// 46 (0x2E) - This plugin adapter cannot be added to an Asset #[error("This plugin adapter cannot be added to an Asset")] InvalidPluginAdapterTarget, - /// 46 (0x2E) - Cannot add a Data Section without a linked external plugin + /// 47 (0x2F) - Cannot add a Data Section without a linked external plugin #[error("Cannot add a Data Section without a linked external plugin")] CannotAddDataSection, } diff --git a/idls/mpl_core.json b/idls/mpl_core.json index 26f49b23..85960483 100644 --- a/idls/mpl_core.json +++ b/idls/mpl_core.json @@ -4825,26 +4825,31 @@ }, { "code": 42, + "name": "CollectionMustBeEmpty", + "msg": "Collection must be empty to be burned" + }, + { + "code": 43, "name": "TwoDataSources", "msg": "Two data sources provided, only one is allowed" }, { - "code": 43, + "code": 44, "name": "UnsupportedOperation", "msg": "External Plugin does not support this operation" }, { - "code": 44, + "code": 45, "name": "NoDataSources", "msg": "No data sources provided, one is required" }, { - "code": 45, + "code": 46, "name": "InvalidPluginAdapterTarget", "msg": "This plugin adapter cannot be added to an Asset" }, { - "code": 46, + "code": 47, "name": "CannotAddDataSection", "msg": "Cannot add a Data Section without a linked external plugin" } diff --git a/programs/mpl-core/src/error.rs b/programs/mpl-core/src/error.rs index 2a0c97c0..266684c2 100644 --- a/programs/mpl-core/src/error.rs +++ b/programs/mpl-core/src/error.rs @@ -177,23 +177,27 @@ pub enum MplCoreError { #[error("Invalid plugin operation")] InvalidPluginOperation, - /// 42 - Two data sources provided, only one is allowed + /// 42 - Collection must be empty to be burned + #[error("Collection must be empty to be burned")] + CollectionMustBeEmpty, + + /// 43 - Two data sources provided, only one is allowed #[error("Two data sources provided, only one is allowed")] TwoDataSources, - /// 43 - External Plugin does not support this operation + /// 44 - External Plugin does not support this operation #[error("External Plugin does not support this operation")] UnsupportedOperation, - /// 44 - No data sources provided, one is required + /// 45 - No data sources provided, one is required #[error("No data sources provided, one is required")] NoDataSources, - /// 45 - This plugin adapter cannot be added to an Asset + /// 46 - This plugin adapter cannot be added to an Asset #[error("This plugin adapter cannot be added to an Asset")] InvalidPluginAdapterTarget, - /// 46 - Cannot add a Data Section without a linked external plugin + /// 47 - Cannot add a Data Section without a linked external plugin #[error("Cannot add a Data Section without a linked external plugin")] CannotAddDataSection, } diff --git a/programs/mpl-core/src/processor/burn.rs b/programs/mpl-core/src/processor/burn.rs index 486de812..0fad2e53 100644 --- a/programs/mpl-core/src/processor/burn.rs +++ b/programs/mpl-core/src/processor/burn.rs @@ -9,7 +9,7 @@ use crate::{ state::{AssetV1, CollectionV1, CompressionProof, Key, SolanaAccount, Wrappable}, utils::{ close_program_account, load_key, rebuild_account_state_from_proof_data, resolve_authority, - validate_asset_permissions, validate_collection_permissions, verify_proof, + validate_asset_permissions, verify_proof, }, }; @@ -132,21 +132,15 @@ pub(crate) fn burn_collection<'a>( } } - // Validate collection permissions. - let _ = validate_collection_permissions( - accounts, - authority, - ctx.accounts.collection, - None, - 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 969293e6..dce8f2ab 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 {