From 6e37a98c4ca6c65c8aa783fbeb6a3fedd95339c2 Mon Sep 17 00:00:00 2001 From: Andrew Robinson Date: Mon, 17 Nov 2025 23:45:01 +0000 Subject: [PATCH] Implement close tree instruction to program and expose in typescript + rust SDK. Generated clients through Kinobi. --- .../src/generated/instructions/closeTree.ts | 146 +++++ .../js/src/generated/instructions/index.ts | 1 + .../src/generated/instructions/close_tree.rs | 514 ++++++++++++++++++ .../rust/src/generated/instructions/mod.rs | 2 + idls/bubblegum.json | 54 ++ programs/bubblegum/program/src/lib.rs | 7 + .../program/src/processor/close_tree.rs | 77 +++ .../bubblegum/program/src/processor/mod.rs | 2 + 8 files changed, 803 insertions(+) create mode 100644 clients/js/src/generated/instructions/closeTree.ts create mode 100644 clients/rust/src/generated/instructions/close_tree.rs create mode 100644 programs/bubblegum/program/src/processor/close_tree.rs diff --git a/clients/js/src/generated/instructions/closeTree.ts b/clients/js/src/generated/instructions/closeTree.ts new file mode 100644 index 0000000..fbfa04f --- /dev/null +++ b/clients/js/src/generated/instructions/closeTree.ts @@ -0,0 +1,146 @@ +/** + * This code was AUTOGENERATED using the kinobi library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun kinobi to update it. + * + * @see https://github.com/metaplex-foundation/kinobi + */ + +import { + Context, + Pda, + PublicKey, + Signer, + TransactionBuilder, + transactionBuilder, +} from '@metaplex-foundation/umi'; +import { + Serializer, + array, + mapSerializer, + struct, + u8, +} from '@metaplex-foundation/umi/serializers'; +import { findTreeConfigPda } from '../accounts'; +import { + ResolvedAccount, + ResolvedAccountsWithIndices, + expectPublicKey, + getAccountMetasAndSigners, +} from '../shared'; + +// Accounts. +export type CloseTreeInstructionAccounts = { + treeConfig?: PublicKey | Pda; + /** Tree creator or delegate. */ + authority?: Signer; + merkleTree: PublicKey | Pda; + /** + * Recipient for reclaimed lamports (tree + config PDA). Must be the creator + * or the delegate. + */ + + recipient: PublicKey | Pda; + compressionProgram: PublicKey | Pda; + logWrapper: PublicKey | Pda; + systemProgram?: PublicKey | Pda; +}; + +// Data. +export type CloseTreeInstructionData = { discriminator: Array }; + +export type CloseTreeInstructionDataArgs = {}; + +export function getCloseTreeInstructionDataSerializer(): Serializer< + CloseTreeInstructionDataArgs, + CloseTreeInstructionData +> { + return mapSerializer< + CloseTreeInstructionDataArgs, + any, + CloseTreeInstructionData + >( + struct( + [['discriminator', array(u8(), { size: 8 })]], + { description: 'CloseTreeInstructionData' } + ), + (value) => ({ + ...value, + discriminator: [9, 124, 164, 131, 238, 218, 148, 212], + }) + ) as Serializer; +} + +// Instruction. +export function closeTree( + context: Pick, + input: CloseTreeInstructionAccounts +): TransactionBuilder { + // Program ID. + const programId = context.programs.getPublicKey( + 'mplBubblegum', + 'BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY' + ); + + // Accounts. + const resolvedAccounts: ResolvedAccountsWithIndices = { + treeConfig: { index: 0, isWritable: true, value: input.treeConfig ?? null }, + authority: { index: 1, isWritable: false, value: input.authority ?? null }, + merkleTree: { index: 2, isWritable: true, value: input.merkleTree ?? null }, + recipient: { index: 3, isWritable: true, value: input.recipient ?? null }, + compressionProgram: { + index: 4, + isWritable: false, + value: input.compressionProgram ?? null, + }, + logWrapper: { + index: 5, + isWritable: false, + value: input.logWrapper ?? null, + }, + systemProgram: { + index: 6, + isWritable: false, + value: input.systemProgram ?? null, + }, + }; + + // Default values. + if (!resolvedAccounts.treeConfig.value) { + resolvedAccounts.treeConfig.value = findTreeConfigPda(context, { + merkleTree: expectPublicKey(resolvedAccounts.merkleTree.value), + }); + } + if (!resolvedAccounts.authority.value) { + resolvedAccounts.authority.value = context.identity; + } + if (!resolvedAccounts.systemProgram.value) { + resolvedAccounts.systemProgram.value = context.programs.getPublicKey( + 'splSystem', + '11111111111111111111111111111111' + ); + resolvedAccounts.systemProgram.isWritable = false; + } + + // 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 = getCloseTreeInstructionDataSerializer().serialize({}); + + // Bytes Created On Chain. + const bytesCreatedOnChain = 0; + + return transactionBuilder([ + { instruction: { keys, programId, data }, signers, bytesCreatedOnChain }, + ]); +} diff --git a/clients/js/src/generated/instructions/index.ts b/clients/js/src/generated/instructions/index.ts index 5b47c35..a56bf09 100644 --- a/clients/js/src/generated/instructions/index.ts +++ b/clients/js/src/generated/instructions/index.ts @@ -9,6 +9,7 @@ export * from './burn'; export * from './burnV2'; export * from './cancelRedeem'; +export * from './closeTree'; export * from './collectV2'; export * from './createTreeConfig'; export * from './createTreeConfigV2'; diff --git a/clients/rust/src/generated/instructions/close_tree.rs b/clients/rust/src/generated/instructions/close_tree.rs new file mode 100644 index 0000000..60d24c6 --- /dev/null +++ b/clients/rust/src/generated/instructions/close_tree.rs @@ -0,0 +1,514 @@ +//! This code was AUTOGENERATED using the kinobi library. +//! Please DO NOT EDIT THIS FILE, instead use visitors +//! to add features, then rerun kinobi to update it. +//! +//! [https://github.com/metaplex-foundation/kinobi] +//! + +use borsh::BorshDeserialize; +use borsh::BorshSerialize; + +/// Accounts. +pub struct CloseTree { + pub tree_config: solana_program::pubkey::Pubkey, + /// Tree creator or delegate. + pub authority: solana_program::pubkey::Pubkey, + + pub merkle_tree: solana_program::pubkey::Pubkey, + /// Recipient for reclaimed lamports (tree + config PDA). Must be the creator + /// or the delegate. + pub recipient: solana_program::pubkey::Pubkey, + + pub compression_program: solana_program::pubkey::Pubkey, + + pub log_wrapper: solana_program::pubkey::Pubkey, + + pub system_program: solana_program::pubkey::Pubkey, +} + +impl CloseTree { + pub fn instruction(&self) -> solana_program::instruction::Instruction { + self.instruction_with_remaining_accounts(&[]) + } + #[allow(clippy::vec_init_then_push)] + pub fn instruction_with_remaining_accounts( + &self, + remaining_accounts: &[solana_program::instruction::AccountMeta], + ) -> solana_program::instruction::Instruction { + let mut accounts = Vec::with_capacity(7 + remaining_accounts.len()); + accounts.push(solana_program::instruction::AccountMeta::new( + self.tree_config, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new_readonly( + self.authority, + true, + )); + accounts.push(solana_program::instruction::AccountMeta::new( + self.merkle_tree, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new( + self.recipient, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new_readonly( + self.compression_program, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new_readonly( + self.log_wrapper, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new_readonly( + self.system_program, + false, + )); + accounts.extend_from_slice(remaining_accounts); + let data = CloseTreeInstructionData::new().try_to_vec().unwrap(); + + solana_program::instruction::Instruction { + program_id: crate::MPL_BUBBLEGUM_ID, + accounts, + data, + } + } +} + +#[derive(BorshDeserialize, BorshSerialize)] +struct CloseTreeInstructionData { + discriminator: [u8; 8], +} + +impl CloseTreeInstructionData { + fn new() -> Self { + Self { + discriminator: [9, 124, 164, 131, 238, 218, 148, 212], + } + } +} + +/// Instruction builder. +#[derive(Default)] +pub struct CloseTreeBuilder { + tree_config: Option, + authority: Option, + merkle_tree: Option, + recipient: Option, + compression_program: Option, + log_wrapper: Option, + system_program: Option, + __remaining_accounts: Vec, +} + +impl CloseTreeBuilder { + pub fn new() -> Self { + Self::default() + } + #[inline(always)] + pub fn tree_config(&mut self, tree_config: solana_program::pubkey::Pubkey) -> &mut Self { + self.tree_config = Some(tree_config); + self + } + /// Tree creator or delegate. + #[inline(always)] + pub fn authority(&mut self, authority: solana_program::pubkey::Pubkey) -> &mut Self { + self.authority = Some(authority); + self + } + #[inline(always)] + pub fn merkle_tree(&mut self, merkle_tree: solana_program::pubkey::Pubkey) -> &mut Self { + self.merkle_tree = Some(merkle_tree); + self + } + /// Recipient for reclaimed lamports (tree + config PDA). Must be the creator + /// or the delegate. + #[inline(always)] + pub fn recipient(&mut self, recipient: solana_program::pubkey::Pubkey) -> &mut Self { + self.recipient = Some(recipient); + self + } + #[inline(always)] + pub fn compression_program( + &mut self, + compression_program: solana_program::pubkey::Pubkey, + ) -> &mut Self { + self.compression_program = Some(compression_program); + self + } + #[inline(always)] + pub fn log_wrapper(&mut self, log_wrapper: solana_program::pubkey::Pubkey) -> &mut Self { + self.log_wrapper = Some(log_wrapper); + self + } + /// `[optional account, default to '11111111111111111111111111111111']` + #[inline(always)] + pub fn system_program(&mut self, system_program: solana_program::pubkey::Pubkey) -> &mut Self { + self.system_program = Some(system_program); + self + } + /// Add an aditional account to the instruction. + #[inline(always)] + pub fn add_remaining_account( + &mut self, + account: solana_program::instruction::AccountMeta, + ) -> &mut Self { + self.__remaining_accounts.push(account); + self + } + /// Add additional accounts to the instruction. + #[inline(always)] + pub fn add_remaining_accounts( + &mut self, + accounts: &[solana_program::instruction::AccountMeta], + ) -> &mut Self { + self.__remaining_accounts.extend_from_slice(accounts); + self + } + #[allow(clippy::clone_on_copy)] + pub fn instruction(&self) -> solana_program::instruction::Instruction { + let accounts = CloseTree { + tree_config: self.tree_config.expect("tree_config is not set"), + authority: self.authority.expect("authority is not set"), + merkle_tree: self.merkle_tree.expect("merkle_tree is not set"), + recipient: self.recipient.expect("recipient is not set"), + compression_program: self + .compression_program + .expect("compression_program is not set"), + log_wrapper: self.log_wrapper.expect("log_wrapper is not set"), + system_program: self + .system_program + .unwrap_or(solana_program::pubkey!("11111111111111111111111111111111")), + }; + + accounts.instruction_with_remaining_accounts(&self.__remaining_accounts) + } +} + +/// `close_tree` CPI accounts. +pub struct CloseTreeCpiAccounts<'a, 'b> { + pub tree_config: &'b solana_program::account_info::AccountInfo<'a>, + /// Tree creator or delegate. + pub authority: &'b solana_program::account_info::AccountInfo<'a>, + + pub merkle_tree: &'b solana_program::account_info::AccountInfo<'a>, + /// Recipient for reclaimed lamports (tree + config PDA). Must be the creator + /// or the delegate. + pub recipient: &'b solana_program::account_info::AccountInfo<'a>, + + pub compression_program: &'b solana_program::account_info::AccountInfo<'a>, + + pub log_wrapper: &'b solana_program::account_info::AccountInfo<'a>, + + pub system_program: &'b solana_program::account_info::AccountInfo<'a>, +} + +/// `close_tree` CPI instruction. +pub struct CloseTreeCpi<'a, 'b> { + /// The program to invoke. + pub __program: &'b solana_program::account_info::AccountInfo<'a>, + + pub tree_config: &'b solana_program::account_info::AccountInfo<'a>, + /// Tree creator or delegate. + pub authority: &'b solana_program::account_info::AccountInfo<'a>, + + pub merkle_tree: &'b solana_program::account_info::AccountInfo<'a>, + /// Recipient for reclaimed lamports (tree + config PDA). Must be the creator + /// or the delegate. + pub recipient: &'b solana_program::account_info::AccountInfo<'a>, + + pub compression_program: &'b solana_program::account_info::AccountInfo<'a>, + + pub log_wrapper: &'b solana_program::account_info::AccountInfo<'a>, + + pub system_program: &'b solana_program::account_info::AccountInfo<'a>, +} + +impl<'a, 'b> CloseTreeCpi<'a, 'b> { + pub fn new( + program: &'b solana_program::account_info::AccountInfo<'a>, + accounts: CloseTreeCpiAccounts<'a, 'b>, + ) -> Self { + Self { + __program: program, + tree_config: accounts.tree_config, + authority: accounts.authority, + merkle_tree: accounts.merkle_tree, + recipient: accounts.recipient, + compression_program: accounts.compression_program, + log_wrapper: accounts.log_wrapper, + system_program: accounts.system_program, + } + } + #[inline(always)] + pub fn invoke(&self) -> solana_program::entrypoint::ProgramResult { + self.invoke_signed_with_remaining_accounts(&[], &[]) + } + #[inline(always)] + pub fn invoke_with_remaining_accounts( + &self, + remaining_accounts: &[( + &'b solana_program::account_info::AccountInfo<'a>, + bool, + bool, + )], + ) -> solana_program::entrypoint::ProgramResult { + self.invoke_signed_with_remaining_accounts(&[], remaining_accounts) + } + #[inline(always)] + pub fn invoke_signed( + &self, + signers_seeds: &[&[&[u8]]], + ) -> solana_program::entrypoint::ProgramResult { + self.invoke_signed_with_remaining_accounts(signers_seeds, &[]) + } + #[allow(clippy::clone_on_copy)] + #[allow(clippy::vec_init_then_push)] + pub fn invoke_signed_with_remaining_accounts( + &self, + signers_seeds: &[&[&[u8]]], + remaining_accounts: &[( + &'b solana_program::account_info::AccountInfo<'a>, + bool, + bool, + )], + ) -> solana_program::entrypoint::ProgramResult { + let mut accounts = Vec::with_capacity(7 + remaining_accounts.len()); + accounts.push(solana_program::instruction::AccountMeta::new( + *self.tree_config.key, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new_readonly( + *self.authority.key, + true, + )); + accounts.push(solana_program::instruction::AccountMeta::new( + *self.merkle_tree.key, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new( + *self.recipient.key, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new_readonly( + *self.compression_program.key, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new_readonly( + *self.log_wrapper.key, + false, + )); + accounts.push(solana_program::instruction::AccountMeta::new_readonly( + *self.system_program.key, + false, + )); + remaining_accounts.iter().for_each(|remaining_account| { + accounts.push(solana_program::instruction::AccountMeta { + pubkey: *remaining_account.0.key, + is_signer: remaining_account.1, + is_writable: remaining_account.2, + }) + }); + let data = CloseTreeInstructionData::new().try_to_vec().unwrap(); + + let instruction = solana_program::instruction::Instruction { + program_id: crate::MPL_BUBBLEGUM_ID, + accounts, + data, + }; + let mut account_infos = Vec::with_capacity(7 + 1 + remaining_accounts.len()); + account_infos.push(self.__program.clone()); + account_infos.push(self.tree_config.clone()); + account_infos.push(self.authority.clone()); + account_infos.push(self.merkle_tree.clone()); + account_infos.push(self.recipient.clone()); + account_infos.push(self.compression_program.clone()); + account_infos.push(self.log_wrapper.clone()); + account_infos.push(self.system_program.clone()); + remaining_accounts + .iter() + .for_each(|remaining_account| account_infos.push(remaining_account.0.clone())); + + if signers_seeds.is_empty() { + solana_program::program::invoke(&instruction, &account_infos) + } else { + solana_program::program::invoke_signed(&instruction, &account_infos, signers_seeds) + } + } +} + +/// `close_tree` CPI instruction builder. +pub struct CloseTreeCpiBuilder<'a, 'b> { + instruction: Box>, +} + +impl<'a, 'b> CloseTreeCpiBuilder<'a, 'b> { + pub fn new(program: &'b solana_program::account_info::AccountInfo<'a>) -> Self { + let instruction = Box::new(CloseTreeCpiBuilderInstruction { + __program: program, + tree_config: None, + authority: None, + merkle_tree: None, + recipient: None, + compression_program: None, + log_wrapper: None, + system_program: None, + __remaining_accounts: Vec::new(), + }); + Self { instruction } + } + #[inline(always)] + pub fn tree_config( + &mut self, + tree_config: &'b solana_program::account_info::AccountInfo<'a>, + ) -> &mut Self { + self.instruction.tree_config = Some(tree_config); + self + } + /// Tree creator or delegate. + #[inline(always)] + pub fn authority( + &mut self, + authority: &'b solana_program::account_info::AccountInfo<'a>, + ) -> &mut Self { + self.instruction.authority = Some(authority); + self + } + #[inline(always)] + pub fn merkle_tree( + &mut self, + merkle_tree: &'b solana_program::account_info::AccountInfo<'a>, + ) -> &mut Self { + self.instruction.merkle_tree = Some(merkle_tree); + self + } + /// Recipient for reclaimed lamports (tree + config PDA). Must be the creator + /// or the delegate. + #[inline(always)] + pub fn recipient( + &mut self, + recipient: &'b solana_program::account_info::AccountInfo<'a>, + ) -> &mut Self { + self.instruction.recipient = Some(recipient); + self + } + #[inline(always)] + pub fn compression_program( + &mut self, + compression_program: &'b solana_program::account_info::AccountInfo<'a>, + ) -> &mut Self { + self.instruction.compression_program = Some(compression_program); + self + } + #[inline(always)] + pub fn log_wrapper( + &mut self, + log_wrapper: &'b solana_program::account_info::AccountInfo<'a>, + ) -> &mut Self { + self.instruction.log_wrapper = Some(log_wrapper); + self + } + #[inline(always)] + pub fn system_program( + &mut self, + system_program: &'b solana_program::account_info::AccountInfo<'a>, + ) -> &mut Self { + self.instruction.system_program = Some(system_program); + self + } + /// Add an additional account to the instruction. + #[inline(always)] + pub fn add_remaining_account( + &mut self, + account: &'b solana_program::account_info::AccountInfo<'a>, + is_writable: bool, + is_signer: bool, + ) -> &mut Self { + self.instruction + .__remaining_accounts + .push((account, is_writable, is_signer)); + self + } + /// Add additional accounts to the instruction. + /// + /// Each account is represented by a tuple of the `AccountInfo`, a `bool` indicating whether the account is writable or not, + /// and a `bool` indicating whether the account is a signer or not. + #[inline(always)] + pub fn add_remaining_accounts( + &mut self, + accounts: &[( + &'b solana_program::account_info::AccountInfo<'a>, + bool, + bool, + )], + ) -> &mut Self { + self.instruction + .__remaining_accounts + .extend_from_slice(accounts); + self + } + #[inline(always)] + pub fn invoke(&self) -> solana_program::entrypoint::ProgramResult { + self.invoke_signed(&[]) + } + #[allow(clippy::clone_on_copy)] + #[allow(clippy::vec_init_then_push)] + pub fn invoke_signed( + &self, + signers_seeds: &[&[&[u8]]], + ) -> solana_program::entrypoint::ProgramResult { + let instruction = CloseTreeCpi { + __program: self.instruction.__program, + + tree_config: self + .instruction + .tree_config + .expect("tree_config is not set"), + + authority: self.instruction.authority.expect("authority is not set"), + + merkle_tree: self + .instruction + .merkle_tree + .expect("merkle_tree is not set"), + + recipient: self.instruction.recipient.expect("recipient is not set"), + + compression_program: self + .instruction + .compression_program + .expect("compression_program is not set"), + + log_wrapper: self + .instruction + .log_wrapper + .expect("log_wrapper is not set"), + + system_program: self + .instruction + .system_program + .expect("system_program is not set"), + }; + instruction.invoke_signed_with_remaining_accounts( + signers_seeds, + &self.instruction.__remaining_accounts, + ) + } +} + +struct CloseTreeCpiBuilderInstruction<'a, 'b> { + __program: &'b solana_program::account_info::AccountInfo<'a>, + tree_config: Option<&'b solana_program::account_info::AccountInfo<'a>>, + authority: Option<&'b solana_program::account_info::AccountInfo<'a>>, + merkle_tree: Option<&'b solana_program::account_info::AccountInfo<'a>>, + recipient: Option<&'b solana_program::account_info::AccountInfo<'a>>, + compression_program: Option<&'b solana_program::account_info::AccountInfo<'a>>, + log_wrapper: Option<&'b solana_program::account_info::AccountInfo<'a>>, + system_program: Option<&'b solana_program::account_info::AccountInfo<'a>>, + /// Additional instruction accounts `(AccountInfo, is_writable, is_signer)`. + __remaining_accounts: Vec<( + &'b solana_program::account_info::AccountInfo<'a>, + bool, + bool, + )>, +} diff --git a/clients/rust/src/generated/instructions/mod.rs b/clients/rust/src/generated/instructions/mod.rs index 331ab94..2e70db6 100644 --- a/clients/rust/src/generated/instructions/mod.rs +++ b/clients/rust/src/generated/instructions/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod burn; pub(crate) mod burn_v2; pub(crate) mod cancel_redeem; +pub(crate) mod close_tree; pub(crate) mod collect_v2; pub(crate) mod create_tree_config; pub(crate) mod create_tree_config_v2; @@ -42,6 +43,7 @@ pub(crate) mod verify_creator_v2; pub use self::burn::*; pub use self::burn_v2::*; pub use self::cancel_redeem::*; +pub use self::close_tree::*; pub use self::collect_v2::*; pub use self::create_tree_config::*; pub use self::create_tree_config_v2::*; diff --git a/idls/bubblegum.json b/idls/bubblegum.json index f29e81d..73a318b 100644 --- a/idls/bubblegum.json +++ b/idls/bubblegum.json @@ -3384,6 +3384,57 @@ } } ] + }, + { + "name": "closeTree", + "docs": [ + "Closes an empty tree and its config PDA to reclaim rent." + ], + "accounts": [ + { + "name": "treeAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": true, + "docs": [ + "Tree creator or delegate." + ] + }, + { + "name": "merkleTree", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false, + "docs": [ + "Recipient for reclaimed lamports (tree + config PDA). Must be the creator", + "or the delegate." + ] + }, + { + "name": "compressionProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "logWrapper", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] } ], "accounts": [ @@ -4079,6 +4130,9 @@ }, { "name": "VerifyCreatorV2" + }, + { + "name": "CloseTree" } ] } diff --git a/programs/bubblegum/program/src/lib.rs b/programs/bubblegum/program/src/lib.rs index 1f06345..ce23567 100644 --- a/programs/bubblegum/program/src/lib.rs +++ b/programs/bubblegum/program/src/lib.rs @@ -56,6 +56,7 @@ pub enum InstructionName { UpdateAssetDataV2, UpdateMetadataV2, VerifyCreatorV2, + CloseTree, } pub fn get_instruction_type(full_bytes: &[u8]) -> InstructionName { @@ -100,6 +101,7 @@ pub fn get_instruction_type(full_bytes: &[u8]) -> InstructionName { [59, 56, 111, 43, 95, 14, 11, 61] => InstructionName::UpdateAssetDataV2, [43, 103, 89, 42, 121, 242, 62, 72] => InstructionName::UpdateMetadataV2, [85, 138, 140, 42, 22, 241, 118, 102] => InstructionName::VerifyCreatorV2, + [9, 124, 164, 131, 238, 218, 148, 212] => InstructionName::CloseTree, _ => InstructionName::Unknown, } } @@ -633,4 +635,9 @@ pub mod bubblegum { ) -> Result<()> { processor::verify_creator_v2(ctx, root, asset_data_hash, flags, nonce, index, message) } + + /// Closes an empty tree and its config PDA to reclaim rent. + pub fn close_tree(ctx: Context) -> Result<()> { + processor::close_tree(ctx) + } } diff --git a/programs/bubblegum/program/src/processor/close_tree.rs b/programs/bubblegum/program/src/processor/close_tree.rs new file mode 100644 index 0000000..f1b9bf2 --- /dev/null +++ b/programs/bubblegum/program/src/processor/close_tree.rs @@ -0,0 +1,77 @@ +use anchor_lang::prelude::*; +use mpl_account_compression::{program::MplAccountCompression, Noop as MplNoop}; +use crate::{ + error::BubblegumError, + state::{leaf_schema::Version, TreeConfig}, +}; + +#[derive(Accounts)] +pub struct CloseTree<'info> { + #[account( + mut, + seeds = [merkle_tree.key().as_ref()], + bump, + )] + pub tree_authority: Account<'info, TreeConfig>, + /// Tree creator or delegate. + pub authority: Signer<'info>, + /// CHECK: This account is modified in the downstream program. + #[account(mut, owner = mpl_account_compression::ID)] + pub merkle_tree: UncheckedAccount<'info>, + /// Recipient for reclaimed lamports (tree + config PDA). Must be the creator + /// or the delegate. + /// CHECK: This account is validated in the instruction. + #[account(mut)] + pub recipient: UncheckedAccount<'info>, + pub compression_program: Program<'info, MplAccountCompression>, + pub log_wrapper: Program<'info, MplNoop>, + pub system_program: Program<'info, System>, +} + +pub(crate) fn close_tree( + ctx: Context +) -> Result<()> { + + // Only V2 trees (created via `create_tree_v2`) are supported. + require!( + ctx.accounts.tree_authority.version == Version::V2, + BubblegumError::UnsupportedSchemaVersion + ); + + // Only the creator or delegate can trigger closure. + let authority = ctx.accounts.authority.key(); + require!( + authority == ctx.accounts.tree_authority.tree_creator + || authority == ctx.accounts.tree_authority.tree_delegate, + BubblegumError::InvalidAuthority + ); + + // Recipient must be the creator or delegate as well. + require!( + ctx.accounts.recipient.key() == ctx.accounts.tree_authority.tree_creator + || ctx.accounts.recipient.key() == ctx.accounts.tree_authority.tree_delegate, + BubblegumError::InvalidAuthority + ); + + // Close the empty tree via CPI using the tree authority PDA as the signer. + let merkle_tree = ctx.accounts.merkle_tree.to_account_info(); + let seed = merkle_tree.key(); + let seeds = &[seed.as_ref(), &[ctx.bumps.tree_authority]]; + let authority_pda_signer = &[&seeds[..]]; + + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.compression_program.to_account_info(), + mpl_account_compression::cpi::accounts::CloseTree { + merkle_tree: ctx.accounts.merkle_tree.to_account_info(), + authority: ctx.accounts.tree_authority.to_account_info(), + recipient: ctx.accounts.recipient.to_account_info(), + }, + authority_pda_signer, + ); + mpl_account_compression::cpi::close_empty_tree(cpi_ctx)?; + + // Close the tree config PDA to reclaim its rent. + ctx.accounts + .tree_authority + .close(ctx.accounts.recipient.to_account_info()) +} diff --git a/programs/bubblegum/program/src/processor/mod.rs b/programs/bubblegum/program/src/processor/mod.rs index 2151984..d4be475 100644 --- a/programs/bubblegum/program/src/processor/mod.rs +++ b/programs/bubblegum/program/src/processor/mod.rs @@ -28,6 +28,7 @@ use crate::{ mod burn; mod cancel_redeem; mod collect; +mod close_tree; mod compress; mod create_tree; mod decompress; @@ -55,6 +56,7 @@ mod verify_creator; pub(crate) use burn::*; pub(crate) use cancel_redeem::*; pub(crate) use collect::*; +pub(crate) use close_tree::*; pub(crate) use compress::*; pub(crate) use create_tree::*; pub(crate) use decompress::*;