diff --git a/clients/js/src/generated/errors/mplAsset.ts b/clients/js/src/generated/errors/mplAsset.ts index eb9eb44a..1c8c16ce 100644 --- a/clients/js/src/generated/errors/mplAsset.ts +++ b/clients/js/src/generated/errors/mplAsset.ts @@ -226,6 +226,19 @@ export class PluginAlreadyExistsError extends ProgramError { codeToErrorMap.set(0xf, PluginAlreadyExistsError); nameToErrorMap.set('PluginAlreadyExists', PluginAlreadyExistsError); +/** NumericalOverflowError: Numerical overflow */ +export class NumericalOverflowErrorError extends ProgramError { + readonly name: string = 'NumericalOverflowError'; + + readonly code: number = 0x10; // 16 + + constructor(program: Program, cause?: Error) { + super('Numerical overflow', program, cause); + } +} +codeToErrorMap.set(0x10, NumericalOverflowErrorError); +nameToErrorMap.set('NumericalOverflowError', NumericalOverflowErrorError); + /** * Attempts to resolve a custom program error from the provided error code. * @category Errors diff --git a/clients/js/src/generated/instructions/burn.ts b/clients/js/src/generated/instructions/burn.ts index f446c2df..87d06b4a 100644 --- a/clients/js/src/generated/instructions/burn.ts +++ b/clients/js/src/generated/instructions/burn.ts @@ -8,6 +8,8 @@ import { Context, + Option, + OptionOrNullable, Pda, PublicKey, Signer, @@ -17,6 +19,7 @@ import { import { Serializer, mapSerializer, + option, struct, u8, } from '@metaplex-foundation/umi/serializers'; @@ -25,6 +28,11 @@ import { ResolvedAccountsWithIndices, getAccountMetasAndSigners, } from '../shared'; +import { + CompressionProof, + CompressionProofArgs, + getCompressionProofSerializer, +} from '../types'; // Accounts. export type BurnInstructionAccounts = { @@ -41,26 +49,38 @@ export type BurnInstructionAccounts = { }; // Data. -export type BurnInstructionData = { discriminator: number }; +export type BurnInstructionData = { + discriminator: number; + compressionProof: Option; +}; -export type BurnInstructionDataArgs = {}; +export type BurnInstructionDataArgs = { + compressionProof: OptionOrNullable; +}; export function getBurnInstructionDataSerializer(): Serializer< BurnInstructionDataArgs, BurnInstructionData > { return mapSerializer( - struct([['discriminator', u8()]], { - description: 'BurnInstructionData', - }), + struct( + [ + ['discriminator', u8()], + ['compressionProof', option(getCompressionProofSerializer())], + ], + { description: 'BurnInstructionData' } + ), (value) => ({ ...value, discriminator: 5 }) ) as Serializer; } +// Args. +export type BurnInstructionArgs = BurnInstructionDataArgs; + // Instruction. export function burn( context: Pick, - input: BurnInstructionAccounts + input: BurnInstructionAccounts & BurnInstructionArgs ): TransactionBuilder { // Program ID. const programId = context.programs.getPublicKey( @@ -85,6 +105,9 @@ export function burn( }, }; + // Arguments. + const resolvedArgs: BurnInstructionArgs = { ...input }; + // Default values. if (!resolvedAccounts.authority.value) { resolvedAccounts.authority.value = context.identity; @@ -103,7 +126,9 @@ export function burn( ); // Data. - const data = getBurnInstructionDataSerializer().serialize({}); + const data = getBurnInstructionDataSerializer().serialize( + resolvedArgs as BurnInstructionDataArgs + ); // Bytes Created On Chain. const bytesCreatedOnChain = 0; diff --git a/clients/js/test/burn.test.ts b/clients/js/test/burn.test.ts new file mode 100644 index 00000000..34348f63 --- /dev/null +++ b/clients/js/test/burn.test.ts @@ -0,0 +1,87 @@ +import { assertAccountExists, generateSigner, sol} from '@metaplex-foundation/umi'; +import test from 'ava'; +// import { base58 } from '@metaplex-foundation/umi/serializers'; +import { Asset, DataState, create, fetchAsset, burn, Key } from '../src'; +import { createUmi } from './_setup'; + +test('it can burn an asset as the owner', async (t) => { + // Given a Umi instance and a new signer. + const umi = await createUmi(); + const assetAddress = generateSigner(umi); + + // When we create a new account. + await create(umi, { + dataState: DataState.AccountState, + assetAddress, + name: 'Test Bread', + uri: 'https://example.com/bread', + }).sendAndConfirm(umi); + + // Then an account was created with the correct data. + const beforeAsset = await fetchAsset(umi, assetAddress.publicKey); + // console.log("Account State:", beforeAsset); + t.like(beforeAsset, { + publicKey: assetAddress.publicKey, + updateAuthority: umi.identity.publicKey, + owner: umi.identity.publicKey, + name: 'Test Bread', + uri: 'https://example.com/bread', + }); + + await burn(umi, { + assetAddress: assetAddress.publicKey, + compressionProof: null + }).sendAndConfirm(umi); + + // And the asset address still exists but was resized to 1. + const afterAsset = await umi.rpc.getAccount(assetAddress.publicKey); + t.true(afterAsset.exists); + assertAccountExists(afterAsset); + t.deepEqual(afterAsset.lamports, sol(0.00089784)); + t.is(afterAsset.data.length, 1); + t.is(afterAsset.data[0], Key.Uninitialized); +}); + +test('it cannot burn an asset if not the owner', async (t) => { + // Given a Umi instance and a new signer. + const umi = await createUmi(); + const assetAddress = generateSigner(umi); + const attacker = generateSigner(umi); + + // When we create a new account. + await create(umi, { + dataState: DataState.AccountState, + assetAddress, + name: 'Test Bread', + uri: 'https://example.com/bread', + }).sendAndConfirm(umi); + + // Then an account was created with the correct data. + const beforeAsset = await fetchAsset(umi, assetAddress.publicKey); + // console.log("Account State:", beforeAsset); + t.like(beforeAsset, { + publicKey: assetAddress.publicKey, + updateAuthority: umi.identity.publicKey, + owner: umi.identity.publicKey, + name: 'Test Bread', + uri: 'https://example.com/bread', + }); + + const result = burn(umi, { + assetAddress: assetAddress.publicKey, + compressionProof: null, + authority: attacker, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }) + + const afterAsset = await fetchAsset(umi, assetAddress.publicKey); + // console.log("Account State:", afterAsset); + t.like(afterAsset, { + publicKey: assetAddress.publicKey, + updateAuthority: umi.identity.publicKey, + owner: umi.identity.publicKey, + name: 'Test Bread', + uri: 'https://example.com/bread', + }); +}); diff --git a/clients/rust/src/generated/errors/mpl_asset.rs b/clients/rust/src/generated/errors/mpl_asset.rs index a1820a03..5887e146 100644 --- a/clients/rust/src/generated/errors/mpl_asset.rs +++ b/clients/rust/src/generated/errors/mpl_asset.rs @@ -58,6 +58,9 @@ pub enum MplAssetError { /// 15 (0xF) - Plugin already exists #[error("Plugin already exists")] PluginAlreadyExists, + /// 16 (0x10) - Numerical overflow + #[error("Numerical overflow")] + NumericalOverflowError, } impl solana_program::program_error::PrintProgramError for MplAssetError { diff --git a/clients/rust/src/generated/instructions/burn.rs b/clients/rust/src/generated/instructions/burn.rs index 56a3b950..df486237 100644 --- a/clients/rust/src/generated/instructions/burn.rs +++ b/clients/rust/src/generated/instructions/burn.rs @@ -5,6 +5,7 @@ //! [https://github.com/metaplex-foundation/kinobi] //! +use crate::generated::types::CompressionProof; use borsh::BorshDeserialize; use borsh::BorshSerialize; @@ -23,12 +24,16 @@ pub struct Burn { } impl Burn { - pub fn instruction(&self) -> solana_program::instruction::Instruction { - self.instruction_with_remaining_accounts(&[]) + pub fn instruction( + &self, + args: BurnInstructionArgs, + ) -> solana_program::instruction::Instruction { + self.instruction_with_remaining_accounts(args, &[]) } #[allow(clippy::vec_init_then_push)] pub fn instruction_with_remaining_accounts( &self, + args: BurnInstructionArgs, remaining_accounts: &[solana_program::instruction::AccountMeta], ) -> solana_program::instruction::Instruction { let mut accounts = Vec::with_capacity(5 + remaining_accounts.len()); @@ -70,7 +75,9 @@ impl Burn { )); } accounts.extend_from_slice(remaining_accounts); - let data = BurnInstructionData::new().try_to_vec().unwrap(); + let mut data = BurnInstructionData::new().try_to_vec().unwrap(); + let mut args = args.try_to_vec().unwrap(); + data.append(&mut args); solana_program::instruction::Instruction { program_id: crate::MPL_ASSET_ID, @@ -91,6 +98,12 @@ impl BurnInstructionData { } } +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BurnInstructionArgs { + pub compression_proof: Option, +} + /// Instruction builder. #[derive(Default)] pub struct BurnBuilder { @@ -99,6 +112,7 @@ pub struct BurnBuilder { authority: Option, payer: Option, log_wrapper: Option, + compression_proof: Option, __remaining_accounts: Vec, } @@ -142,6 +156,12 @@ impl BurnBuilder { self.log_wrapper = log_wrapper; self } + /// `[optional argument]` + #[inline(always)] + pub fn compression_proof(&mut self, compression_proof: CompressionProof) -> &mut Self { + self.compression_proof = Some(compression_proof); + self + } /// Add an aditional account to the instruction. #[inline(always)] pub fn add_remaining_account( @@ -169,8 +189,11 @@ impl BurnBuilder { payer: self.payer, log_wrapper: self.log_wrapper, }; + let args = BurnInstructionArgs { + compression_proof: self.compression_proof.clone(), + }; - accounts.instruction_with_remaining_accounts(&self.__remaining_accounts) + accounts.instruction_with_remaining_accounts(args, &self.__remaining_accounts) } } @@ -202,12 +225,15 @@ pub struct BurnCpi<'a, 'b> { pub payer: Option<&'b solana_program::account_info::AccountInfo<'a>>, /// The SPL Noop Program pub log_wrapper: Option<&'b solana_program::account_info::AccountInfo<'a>>, + /// The arguments for the instruction. + pub __args: BurnInstructionArgs, } impl<'a, 'b> BurnCpi<'a, 'b> { pub fn new( program: &'b solana_program::account_info::AccountInfo<'a>, accounts: BurnCpiAccounts<'a, 'b>, + args: BurnInstructionArgs, ) -> Self { Self { __program: program, @@ -216,6 +242,7 @@ impl<'a, 'b> BurnCpi<'a, 'b> { authority: accounts.authority, payer: accounts.payer, log_wrapper: accounts.log_wrapper, + __args: args, } } #[inline(always)] @@ -299,7 +326,9 @@ impl<'a, 'b> BurnCpi<'a, 'b> { is_writable: remaining_account.2, }) }); - let data = BurnInstructionData::new().try_to_vec().unwrap(); + let mut data = BurnInstructionData::new().try_to_vec().unwrap(); + let mut args = self.__args.try_to_vec().unwrap(); + data.append(&mut args); let instruction = solana_program::instruction::Instruction { program_id: crate::MPL_ASSET_ID, @@ -345,6 +374,7 @@ impl<'a, 'b> BurnCpiBuilder<'a, 'b> { authority: None, payer: None, log_wrapper: None, + compression_proof: None, __remaining_accounts: Vec::new(), }); Self { instruction } @@ -397,6 +427,12 @@ impl<'a, 'b> BurnCpiBuilder<'a, 'b> { self.instruction.log_wrapper = log_wrapper; self } + /// `[optional argument]` + #[inline(always)] + pub fn compression_proof(&mut self, compression_proof: CompressionProof) -> &mut Self { + self.instruction.compression_proof = Some(compression_proof); + self + } /// Add an additional account to the instruction. #[inline(always)] pub fn add_remaining_account( @@ -438,6 +474,9 @@ impl<'a, 'b> BurnCpiBuilder<'a, 'b> { &self, signers_seeds: &[&[&[u8]]], ) -> solana_program::entrypoint::ProgramResult { + let args = BurnInstructionArgs { + compression_proof: self.instruction.compression_proof.clone(), + }; let instruction = BurnCpi { __program: self.instruction.__program, @@ -453,6 +492,7 @@ impl<'a, 'b> BurnCpiBuilder<'a, 'b> { payer: self.instruction.payer, log_wrapper: self.instruction.log_wrapper, + __args: args, }; instruction.invoke_signed_with_remaining_accounts( signers_seeds, @@ -468,6 +508,7 @@ struct BurnCpiBuilderInstruction<'a, 'b> { authority: Option<&'b solana_program::account_info::AccountInfo<'a>>, payer: Option<&'b solana_program::account_info::AccountInfo<'a>>, log_wrapper: Option<&'b solana_program::account_info::AccountInfo<'a>>, + compression_proof: Option, /// Additional instruction accounts `(AccountInfo, is_writable, is_signer)`. __remaining_accounts: Vec<( &'b solana_program::account_info::AccountInfo<'a>, diff --git a/idls/mpl_asset_program.json b/idls/mpl_asset_program.json index 5baa8f05..8384e29d 100644 --- a/idls/mpl_asset_program.json +++ b/idls/mpl_asset_program.json @@ -916,7 +916,16 @@ "name": "BurnArgs", "type": { "kind": "struct", - "fields": [] + "fields": [ + { + "name": "compressionProof", + "type": { + "option": { + "defined": "CompressionProof" + } + } + } + ] } }, { @@ -1327,6 +1336,11 @@ "code": 15, "name": "PluginAlreadyExists", "msg": "Plugin already exists" + }, + { + "code": 16, + "name": "NumericalOverflowError", + "msg": "Numerical overflow" } ], "metadata": { diff --git a/programs/mpl-asset/src/error.rs b/programs/mpl-asset/src/error.rs index ca500f72..c615ee68 100644 --- a/programs/mpl-asset/src/error.rs +++ b/programs/mpl-asset/src/error.rs @@ -71,6 +71,10 @@ pub enum MplAssetError { /// 15 - Plugin already exists #[error("Plugin already exists")] PluginAlreadyExists, + + /// 16 - Numerical overflow + #[error("Numerical overflow")] + NumericalOverflowError, } impl PrintProgramError for MplAssetError { diff --git a/programs/mpl-asset/src/processor/burn.rs b/programs/mpl-asset/src/processor/burn.rs index 1830537c..6e57394b 100644 --- a/programs/mpl-asset/src/processor/burn.rs +++ b/programs/mpl-asset/src/processor/burn.rs @@ -1,19 +1,80 @@ use borsh::{BorshDeserialize, BorshSerialize}; use mpl_utils::assert_signer; use solana_program::{ - account_info::AccountInfo, entrypoint::ProgramResult, program::invoke, - program_memory::sol_memcpy, rent::Rent, system_instruction, system_program, sysvar::Sysvar, + account_info::AccountInfo, entrypoint::ProgramResult, program_error::ProgramError, }; use crate::{ error::MplAssetError, - instruction::accounts::CreateAccounts, - state::{Asset, Compressible, DataState, HashedAsset, Key}, + instruction::accounts::BurnAccounts, + plugins::{fetch_plugin, Plugin, PluginType}, + state::{Asset, Compressible, CompressionProof, DataBlob, Key, SolanaAccount}, + utils::{assert_authority, close_program_account, load_key}, }; #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] -pub struct BurnArgs {} +pub struct BurnArgs { + compression_proof: Option, +} pub(crate) fn burn<'a>(accounts: &'a [AccountInfo<'a>], args: BurnArgs) -> ProgramResult { - Ok(()) + // Accounts. + let ctx = BurnAccounts::context(accounts)?; + + // Guards. + assert_signer(ctx.accounts.authority)?; + if let Some(payer) = ctx.accounts.payer { + assert_signer(payer)?; + } + + match load_key(ctx.accounts.asset_address, 0)? { + Key::HashedAsset => { + let compression_proof = args + .compression_proof + .ok_or(MplAssetError::MissingCompressionProof)?; + let asset = Asset::verify_proof(ctx.accounts.asset_address, compression_proof)?; + + if ctx.accounts.authority.key != &asset.owner { + return Err(MplAssetError::InvalidAuthority.into()); + } + + // TODO: Check delegates in compressed case. + + asset.wrap()?; + } + Key::Asset => { + let asset = Asset::load(ctx.accounts.asset_address, 0)?; + + let mut authority_check: Result<(), ProgramError> = + Err(MplAssetError::InvalidAuthority.into()); + if asset.get_size() != ctx.accounts.asset_address.data_len() { + solana_program::msg!("Fetch Plugin"); + let (authorities, plugin, _) = + fetch_plugin(ctx.accounts.asset_address, PluginType::Delegate)?; + + solana_program::msg!("Assert authority"); + authority_check = assert_authority(&asset, ctx.accounts.authority, &authorities); + + if let Plugin::Delegate(delegate) = plugin { + if delegate.frozen { + return Err(MplAssetError::AssetIsFrozen.into()); + } + } + } + + match authority_check { + Ok(_) => Ok::<(), ProgramError>(()), + Err(_) => { + if ctx.accounts.authority.key != &asset.owner { + Err(MplAssetError::InvalidAuthority.into()) + } else { + Ok(()) + } + } + }?; + } + _ => return Err(MplAssetError::IncorrectAccount.into()), + } + + close_program_account(ctx.accounts.asset_address, ctx.accounts.authority) } diff --git a/programs/mpl-asset/src/processor/transfer.rs b/programs/mpl-asset/src/processor/transfer.rs index 97427715..bde1b42f 100644 --- a/programs/mpl-asset/src/processor/transfer.rs +++ b/programs/mpl-asset/src/processor/transfer.rs @@ -35,6 +35,12 @@ pub(crate) fn transfer<'a>(accounts: &'a [AccountInfo<'a>], args: TransferArgs) .ok_or(MplAssetError::MissingCompressionProof)?; let mut asset = Asset::verify_proof(ctx.accounts.asset_address, compression_proof)?; + if ctx.accounts.authority.key != &asset.owner { + return Err(MplAssetError::InvalidAuthority.into()); + } + + // TODO: Check delegates in compressed case. + asset.owner = *ctx.accounts.new_owner.key; asset.wrap()?; diff --git a/programs/mpl-asset/src/state/mod.rs b/programs/mpl-asset/src/state/mod.rs index 085ab012..47bb16eb 100644 --- a/programs/mpl-asset/src/state/mod.rs +++ b/programs/mpl-asset/src/state/mod.rs @@ -5,7 +5,7 @@ mod hashed_asset; pub use hashed_asset::*; mod plugin_header; -use num_derive::FromPrimitive; +use num_derive::{FromPrimitive, ToPrimitive}; pub use plugin_header::*; mod traits; @@ -47,7 +47,9 @@ pub enum ExtraAccounts { owner_pda: Option, }, } -#[derive(Clone, Copy, BorshSerialize, BorshDeserialize, Debug, PartialEq, Eq, FromPrimitive)] +#[derive( + Clone, Copy, BorshSerialize, BorshDeserialize, Debug, PartialEq, Eq, ToPrimitive, FromPrimitive, +)] pub enum Key { Uninitialized, Asset, diff --git a/programs/mpl-asset/src/utils.rs b/programs/mpl-asset/src/utils.rs index 76d42d50..dd7bb8f6 100644 --- a/programs/mpl-asset/src/utils.rs +++ b/programs/mpl-asset/src/utils.rs @@ -1,6 +1,7 @@ -use num_traits::FromPrimitive; +use num_traits::{FromPrimitive, ToPrimitive}; use solana_program::{ - account_info::AccountInfo, entrypoint::ProgramResult, program_error::ProgramError, + account_info::AccountInfo, entrypoint::ProgramResult, program_error::ProgramError, rent::Rent, + sysvar::Sysvar, }; use crate::{ @@ -65,3 +66,30 @@ pub fn fetch_core_data( Ok((asset, None, None)) } } + +pub(crate) fn close_program_account<'a>( + account_to_close_info: &AccountInfo<'a>, + funds_dest_account_info: &AccountInfo<'a>, +) -> ProgramResult { + let rent = Rent::get()?; + + let account_size = account_to_close_info.data_len(); + let account_rent = rent.minimum_balance(account_size); + let one_byte_rent = rent.minimum_balance(1); + + let amount_to_return = account_rent + .checked_sub(one_byte_rent) + .ok_or(MplAssetError::NumericalOverflowError)?; + + // Transfer lamports from the account to the destination account. + let dest_starting_lamports = funds_dest_account_info.lamports(); + **funds_dest_account_info.lamports.borrow_mut() = dest_starting_lamports + .checked_add(amount_to_return) + .ok_or(MplAssetError::NumericalOverflowError)?; + **account_to_close_info.lamports.borrow_mut() = one_byte_rent; + + account_to_close_info.realloc(1, false)?; + account_to_close_info.data.borrow_mut()[0] = Key::Uninitialized.to_u8().unwrap(); + + Ok(()) +}