Skip to content
This repository was archived by the owner on Mar 11, 2025. It is now read-only.

Commit 271c13b

Browse files
authored
Account Compression: Add Close Tree Instruction (#3641)
* ac: add close tree instruction * ac: update sdk with close tree instruction * ac: test close tree instruction
1 parent 50abadd commit 271c13b

File tree

6 files changed

+253
-11
lines changed

6 files changed

+253
-11
lines changed

account-compression/programs/account-compression/src/lib.rs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pub struct Initialize<'info> {
5757
/// CHECK: This account will be zeroed out, and the size will be validated
5858
pub merkle_tree: UncheckedAccount<'info>,
5959

60-
/// Authority that validates the content of the trees.
60+
/// Authority that controls write-access to the tree
6161
/// Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs.
6262
pub authority: Signer<'info>,
6363

@@ -76,7 +76,7 @@ pub struct Modify<'info> {
7676
/// CHECK: This account is validated in the instruction
7777
pub merkle_tree: UncheckedAccount<'info>,
7878

79-
/// Authority that validates the content of the trees.
79+
/// Authority that controls write-access to the tree
8080
/// Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs.
8181
pub authority: Signer<'info>,
8282

@@ -100,11 +100,26 @@ pub struct TransferAuthority<'info> {
100100
/// CHECK: This account is validated in the instruction
101101
pub merkle_tree: UncheckedAccount<'info>,
102102

103-
/// Authority that validates the content of the trees.
103+
/// Authority that controls write-access to the tree
104104
/// Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs.
105105
pub authority: Signer<'info>,
106106
}
107107

108+
/// Context for closing a tree
109+
#[derive(Accounts)]
110+
pub struct CloseTree<'info> {
111+
#[account(mut)]
112+
/// CHECK: This account is validated in the instruction
113+
pub merkle_tree: AccountInfo<'info>,
114+
115+
/// Authority that controls write-access to the tree
116+
pub authority: Signer<'info>,
117+
118+
/// CHECK: Recipient of funds after
119+
#[account(mut)]
120+
pub recipient: AccountInfo<'info>,
121+
}
122+
108123
/// This macro applies functions on a ConcurrentMerkleT:ee and emits leaf information
109124
/// needed to sync the merkle tree state with off-chain indexers.
110125
macro_rules! merkle_tree_depth_size_apply_fn {
@@ -512,4 +527,39 @@ pub mod spl_account_compression {
512527
&ctx.accounts.log_wrapper,
513528
)
514529
}
530+
531+
pub fn close_empty_tree(ctx: Context<CloseTree>) -> Result<()> {
532+
require_eq!(
533+
*ctx.accounts.merkle_tree.owner,
534+
crate::id(),
535+
AccountCompressionError::IncorrectAccountOwner
536+
);
537+
let mut merkle_tree_bytes = ctx.accounts.merkle_tree.try_borrow_mut_data()?;
538+
let (header_bytes, rest) =
539+
merkle_tree_bytes.split_at_mut(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1);
540+
541+
let header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?;
542+
header.assert_valid_authority(&ctx.accounts.authority.key())?;
543+
544+
let merkle_tree_size = merkle_tree_get_size(&header)?;
545+
let (tree_bytes, canopy_bytes) = rest.split_at_mut(merkle_tree_size);
546+
547+
let id = ctx.accounts.merkle_tree.key();
548+
merkle_tree_apply_fn!(header, id, tree_bytes, prove_tree_is_empty,)?;
549+
550+
// Close merkle tree account
551+
// 1. Move lamports
552+
let dest_starting_lamports = ctx.accounts.recipient.lamports();
553+
**ctx.accounts.recipient.lamports.borrow_mut() = dest_starting_lamports
554+
.checked_add(ctx.accounts.merkle_tree.lamports())
555+
.unwrap();
556+
**ctx.accounts.merkle_tree.lamports.borrow_mut() = 0;
557+
558+
// 2. Set all CMT account bytes to 0
559+
header_bytes.fill(0);
560+
tree_bytes.fill(0);
561+
canopy_bytes.fill(0);
562+
563+
Ok(())
564+
}
515565
}

account-compression/sdk/idl/spl_account_compression.json

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"isMut": false,
3030
"isSigner": true,
3131
"docs": [
32-
"Authority that validates the content of the trees.",
32+
"Authority that controls write-access to the tree",
3333
"Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs."
3434
]
3535
},
@@ -81,7 +81,7 @@
8181
"isMut": false,
8282
"isSigner": true,
8383
"docs": [
84-
"Authority that validates the content of the trees.",
84+
"Authority that controls write-access to the tree",
8585
"Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs."
8686
]
8787
},
@@ -146,7 +146,7 @@
146146
"isMut": false,
147147
"isSigner": true,
148148
"docs": [
149-
"Authority that validates the content of the trees.",
149+
"Authority that controls write-access to the tree",
150150
"Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs."
151151
]
152152
}
@@ -217,7 +217,7 @@
217217
"isMut": false,
218218
"isSigner": true,
219219
"docs": [
220-
"Authority that validates the content of the trees.",
220+
"Authority that controls write-access to the tree",
221221
"Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs."
222222
]
223223
},
@@ -262,7 +262,7 @@
262262
"isMut": false,
263263
"isSigner": true,
264264
"docs": [
265-
"Authority that validates the content of the trees.",
265+
"Authority that controls write-access to the tree",
266266
"Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs."
267267
]
268268
},
@@ -300,6 +300,30 @@
300300
"type": "u32"
301301
}
302302
]
303+
},
304+
{
305+
"name": "closeEmptyTree",
306+
"accounts": [
307+
{
308+
"name": "merkleTree",
309+
"isMut": true,
310+
"isSigner": false
311+
},
312+
{
313+
"name": "authority",
314+
"isMut": false,
315+
"isSigner": true,
316+
"docs": [
317+
"Authority that controls write-access to the tree"
318+
]
319+
},
320+
{
321+
"name": "recipient",
322+
"isMut": true,
323+
"isSigner": false
324+
}
325+
],
326+
"args": []
303327
}
304328
],
305329
"types": [
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* This code was GENERATED using the solita package.
3+
* Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality.
4+
*
5+
* See: https://github.com/metaplex-foundation/solita
6+
*/
7+
8+
import * as beet from '@metaplex-foundation/beet'
9+
import * as web3 from '@solana/web3.js'
10+
11+
/**
12+
* @category Instructions
13+
* @category CloseEmptyTree
14+
* @category generated
15+
*/
16+
export const closeEmptyTreeStruct = new beet.BeetArgsStruct<{
17+
instructionDiscriminator: number[] /* size: 8 */
18+
}>(
19+
[['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)]],
20+
'CloseEmptyTreeInstructionArgs'
21+
)
22+
/**
23+
* Accounts required by the _closeEmptyTree_ instruction
24+
*
25+
* @property [_writable_] merkleTree
26+
* @property [**signer**] authority
27+
* @property [_writable_] recipient
28+
* @category Instructions
29+
* @category CloseEmptyTree
30+
* @category generated
31+
*/
32+
export type CloseEmptyTreeInstructionAccounts = {
33+
merkleTree: web3.PublicKey
34+
authority: web3.PublicKey
35+
recipient: web3.PublicKey
36+
anchorRemainingAccounts?: web3.AccountMeta[]
37+
}
38+
39+
export const closeEmptyTreeInstructionDiscriminator = [
40+
50, 14, 219, 107, 78, 103, 16, 103,
41+
]
42+
43+
/**
44+
* Creates a _CloseEmptyTree_ instruction.
45+
*
46+
* @param accounts that will be accessed while the instruction is processed
47+
* @category Instructions
48+
* @category CloseEmptyTree
49+
* @category generated
50+
*/
51+
export function createCloseEmptyTreeInstruction(
52+
accounts: CloseEmptyTreeInstructionAccounts,
53+
programId = new web3.PublicKey('cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK')
54+
) {
55+
const [data] = closeEmptyTreeStruct.serialize({
56+
instructionDiscriminator: closeEmptyTreeInstructionDiscriminator,
57+
})
58+
const keys: web3.AccountMeta[] = [
59+
{
60+
pubkey: accounts.merkleTree,
61+
isWritable: true,
62+
isSigner: false,
63+
},
64+
{
65+
pubkey: accounts.authority,
66+
isWritable: false,
67+
isSigner: true,
68+
},
69+
{
70+
pubkey: accounts.recipient,
71+
isWritable: true,
72+
isSigner: false,
73+
},
74+
]
75+
76+
if (accounts.anchorRemainingAccounts != null) {
77+
for (const acc of accounts.anchorRemainingAccounts) {
78+
keys.push(acc)
79+
}
80+
}
81+
82+
const ix = new web3.TransactionInstruction({
83+
programId,
84+
keys,
85+
data,
86+
})
87+
return ix
88+
}

account-compression/sdk/src/generated/instructions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './append'
2+
export * from './closeEmptyTree'
23
export * from './initEmptyMerkleTree'
34
export * from './insertOrAppend'
45
export * from './replaceLeaf'

account-compression/sdk/src/instructions/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
createTransferAuthorityInstruction,
88
createVerifyLeafInstruction,
99
PROGRAM_ID,
10-
createInitEmptyMerkleTreeInstruction
10+
createInitEmptyMerkleTreeInstruction,
11+
createCloseEmptyTreeInstruction
1112
} from "../generated";
1213

1314
/**
@@ -147,4 +148,18 @@ export async function createAllocTreeIx(
147148
space: requiredSpace,
148149
programId: PROGRAM_ID
149150
});
151+
}
152+
153+
export function createCloseEmptyTreeIx(
154+
authority: PublicKey,
155+
merkleTree: PublicKey,
156+
recipient: PublicKey,
157+
): TransactionInstruction {
158+
return createCloseEmptyTreeInstruction(
159+
{
160+
merkleTree,
161+
authority,
162+
recipient
163+
},
164+
)
150165
}

account-compression/sdk/tests/accountCompression.test.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
createTransferAuthorityIx,
3131
createVerifyLeafIx,
3232
ConcurrentMerkleTreeAccount,
33+
createCloseEmptyTreeInstruction,
3334
} from "../src";
3435

3536
describe("Account Compression", () => {
@@ -324,7 +325,6 @@ describe("Account Compression", () => {
324325
it(`Replace all of them in a block`, async () => {
325326
// Replace 64 leaves before syncing off-chain tree with on-chain tree
326327

327-
// Cache all proofs so we can execute in single block
328328
let ixArray: TransactionInstruction[] = [];
329329
let txList: Promise<string>[] = [];
330330

@@ -351,7 +351,7 @@ describe("Account Compression", () => {
351351
ixArray.push(replaceIx);
352352
}
353353

354-
// Execute all replaces in a "single block"
354+
// Execute all replaces
355355
ixArray.map((ix) => {
356356
txList.push(
357357
execute(provider, [ix], [payer])
@@ -372,6 +372,70 @@ describe("Account Compression", () => {
372372
"Updated on chain root does not match root of updated off chain tree"
373373
);
374374
});
375+
it("Empty all of the leaves and close the tree", async () => {
376+
let ixArray: TransactionInstruction[] = [];
377+
let txList: Promise<string>[] = [];
378+
const leavesToUpdate: Buffer[] = [];
379+
for (let i = 0; i < MAX_SIZE; i++) {
380+
const index = i;
381+
const newLeaf = hash(
382+
payer.publicKey.toBuffer(),
383+
Buffer.from(new BN(i).toArray())
384+
);
385+
leavesToUpdate.push(newLeaf);
386+
const proof = getProofOfLeaf(offChainTree, index);
387+
const replaceIx = createReplaceIx(
388+
payer,
389+
cmtKeypair.publicKey,
390+
offChainTree.root,
391+
offChainTree.leaves[i].node,
392+
Buffer.alloc(32), // Empty node
393+
index,
394+
proof.map((treeNode) => {
395+
return treeNode.node;
396+
})
397+
);
398+
ixArray.push(replaceIx);
399+
}
400+
// Execute all replaces
401+
ixArray.map((ix) => {
402+
txList.push(
403+
execute(provider, [ix], [payer])
404+
);
405+
});
406+
await Promise.all(txList);
407+
408+
let payerInfo = await provider.connection.getAccountInfo(payer.publicKey, "confirmed")!;
409+
let treeInfo = await provider.connection.getAccountInfo(cmtKeypair.publicKey, "confirmed")!;
410+
411+
let payerLamports = payerInfo!.lamports;
412+
let treeLamports = treeInfo!.lamports;
413+
414+
const ix = createCloseEmptyTreeInstruction({
415+
merkleTree: cmtKeypair.publicKey,
416+
authority: payer.publicKey,
417+
recipient: payer.publicKey,
418+
})
419+
await execute(provider, [ix], [payer]);
420+
421+
payerInfo = await provider.connection.getAccountInfo(payer.publicKey, "confirmed")!;
422+
const finalLamports = payerInfo!.lamports;
423+
assert(finalLamports === (payerLamports + treeLamports - 5000), "Expected payer to have received the lamports from the closed tree account");
424+
425+
treeInfo = await provider.connection.getAccountInfo(cmtKeypair.publicKey, "confirmed");
426+
assert(treeInfo === null, "Expected the merkle tree account info to be null");
427+
})
428+
it("It cannot be closed until empty", async () => {
429+
const ix = createCloseEmptyTreeInstruction({
430+
merkleTree: cmtKeypair.publicKey,
431+
authority: payer.publicKey,
432+
recipient: payer.publicKey,
433+
})
434+
try {
435+
await execute(provider, [ix], [payer]);
436+
assert(false, "Closing a tree account before it is empty should ALWAYS error")
437+
} catch (e) { }
438+
})
375439
});
376440

377441
describe(`Having created a tree with depth 3`, () => {

0 commit comments

Comments
 (0)