Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixing collections to be burnable if empty. #162

Merged
merged 3 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions clients/js/src/generated/errors/mplCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 27 additions & 20 deletions clients/js/test/burn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down
130 changes: 130 additions & 0 deletions clients/js/test/burnCollection.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
3 changes: 3 additions & 0 deletions clients/rust/src/generated/errors/mpl_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions idls/mpl_core.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions programs/mpl-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 9 additions & 14 deletions programs/mpl-core/src/processor/burn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion programs/mpl-core/src/state/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValidationResult, ProgramError> {
Expand Down
Loading