Close a PDA account and reclaim rent
On Solana, creating state means locking up a refundable storage deposit. Today you add a close instruction that deletes a counter account, returns the SOL to its owner, and proves the account is really gone.
Close a PDA account and reclaim rent
Your wallet is a little lighter than it was a week ago. Every counter PDA from Days 65 and 66 is holding a small balance of SOL hostage, and the runtime will keep holding it until you ship the instruction that gives it back.
The Scenario
The first time you ran init_counter, the program quietly pulled a fraction of a SOL out of your wallet and parked it inside the new PDA. Solana calls that a rent-exempt deposit. It is the price of telling the runtime that the account is allowed to exist indefinitely without paying ongoing rent. One counter is barely noticeable. Initialize ten of them while debugging the work from Days 65 and 66 and the wallet starts to feel lighter.
Your Web2 instinct here is close to correct. This is the storage-deposit pattern: pay a refundable amount when you create a row, get it back when you delete it. Postgres does not do this because disk is cheap and shared. Solana does, because the validators replicating your account data are spread across thousands of machines and somebody has to be compensated for the bytes you are taking up. The good news is that the network never keeps the deposit. The moment the account stops existing, the lamports move back to whoever you point them at.
What you do not get to do is reach into the runtime yourself and free the bytes. You add one Anchor constraint, write a four-line handler, and let the framework drain the account in its teardown phase. By the end of today you will have an instruction that takes a counter PDA, sends every lamport inside it back to the wallet that owns it, and zeroes the account so nothing else can be done with it.
The Challenge
What you’ll need
- The Anchor project from Day 66, with the per-user counter program that stores both a
userpubkey and abumpon the counter account - A working Anchor toolchain (see the Anchor installation guide)
- A funded devnet keypair (the same one that initialized the counter in earlier days)
- Your editor of choice and a terminal
Steps
- Open
programs/counter/src/lib.rsand add a new instruction handler beneathincrement. The body has nothing to do, because Anchor will do all the real work in the accounts struct.
pub fn close_counter(_ctx: Context<CloseCounter>) -> Result<()> {
Ok(())
}
- At the bottom of the file, next to your other
#[derive(Accounts)]structs, add the accounts struct for the new instruction. Theclose = userattribute is the entire feature you are shipping today.
#[derive(Accounts)]
pub struct CloseCounter<'info> {
#[account(
mut,
close = user,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = user,
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub user: Signer<'info>,
}
Read that struct carefully. close = user tells Anchor to drain the counter’s lamports into the user account and mark the data as closed when the instruction returns. has_one = user is the wall that stops a stranger from closing a counter that does not belong to them, because Anchor will check that the user field stored on the counter matches the signer you passed in. The PDA derivation still uses the same seeds and the stored bump, so the address you are operating on is exactly the one you created two days ago.
- Open
tests/counter.ts, the file you rewrote on Day 66. Add this new test inside the existingdescribeblock, below the other tests. The shape is: initialize a counter, capture the wallet balance and the account’s lamports, callcloseCounter, then assert the account is gone and the rent came back.
it("closes a counter and refunds the rent", async () => {
const user = provider.wallet.publicKey;
const [counterPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("counter"), user.toBuffer()],
program.programId,
);
// Initialize a fresh counter if the previous test already closed it.
const existing = await provider.connection.getAccountInfo(counterPda);
if (existing === null) {
await program.methods.initCounter().rpc();
}
const counterAccount = await provider.connection.getAccountInfo(counterPda);
const rentLamports = counterAccount!.lamports;
const balanceBefore = await provider.connection.getBalance(user);
await program.methods.closeCounter().rpc();
const counterAfter = await provider.connection.getAccountInfo(counterPda);
const balanceAfter = await provider.connection.getBalance(user);
if (counterAfter !== null) {
throw new Error("counter account still exists after close");
}
console.log("rent refunded (lamports):", rentLamports);
console.log("net wallet change (lamports):", balanceAfter - balanceBefore);
});
The first getAccountInfo call tells you how much SOL the account is holding. The second one, after the close, should return null, because the lamport balance hit zero and the runtime swept the account out of existence at the end of the transaction.
- Build the program so your IDL and TypeScript types pick up the new instruction.
anchor build
- Run the tests. Anchor will spin up a local validator, deploy the updated program, and walk through the existing tests plus your new one. Watch the console for the two lines you printed.
Run it
anchor test --validator legacy
The --validator legacy flag points Anchor at the solana-test-validator from your Solana toolchain. Anchor defaults to a separate validator called surfpool, and a bare anchor test without it installed fails with Failed to spawn surfpool: No such file or directory. The ts-mocha test script you set in Anchor.toml on Day 65 still applies, so anchor test runs tests/counter.ts.
What Just Happened
You added one instruction and one accounts struct, and Anchor took care of everything underneath. When close_counter returned Ok(()), the framework went into its teardown phase, transferred every lamport from the counter PDA into the user’s wallet, and wrote a sentinel value over the account’s discriminator so the data is no longer usable. By the time the transaction committed, the runtime saw an account with zero lamports and removed it from the next slot’s account state. That is why getAccountInfo returned null the second time around.
If you point the same test at devnet against the counter you initialized on Day 65, your wallet should end up a little heavier than it was a minute ago. The rent the runtime returned to you is the deposit you posted to keep the account alive, and Solana’s promise has always been that this number is recoverable any time you choose to stop using the space. Every program you build from now on will accumulate state accounts the same way a long-running web service accumulates rows. The close instruction is how you keep that cost bounded without leaving lamports stranded on chain.
One detail worth holding onto: the program refused to close the counter unless the signer matched the user stored on the account. That single line is the difference between a refund instruction and a free-for-all that lets anyone drain anyone else’s rent. The seeds, bump, and has_one constraints all carried over from earlier in the arc, and they are still doing the same job: making sure the address you are operating on is the one the program expects, and that the wallet calling the instruction is the one allowed to act on it.
Resources
- Anchor account constraints reference
- Solana developer course: closing accounts
- QuickNode: understanding rent on Solana
- Anchor PDA basics
Submission
Take a screenshot of your terminal showing the new test passing, with the printed rent refund and net wallet change lines visible, and submit it below