Sign in

Account closure and realloc

Solana accounts are not garbage-collected. Every byte you allocate stays paid for forever, and the SOL locked for rent-exemption stays locked until someone explicitly reclaims it. Closing an account is the deliberate act of saying "I'm done, give the SOL back, and make sure nobody can resurrect this slot with stale state." Resizing an account is the inverse: keeping the account alive but changing how many bytes it holds. Both are operations the default Anchor model does not perform automatically, and getting them wrong has real consequences. This lecture is the mechanics of doing them safely.

Why closure exists

When you initialize an account, the runtime reserves space on chain and the payer locks SOL to cover the rent-exempt minimum for that size. A typical 100-byte account locks about 1.5 million lamports. A 1 KB account locks roughly 7 million. The chain has no concept of "this account is done with." Once allocated, the bytes stay reserved and the SOL stays locked, regardless of whether anyone still uses the account.

If a million users each create a small record they never come back to, that's a million accounts of locked SOL drifting on the chain forever. The protocol that issued those records is responsible for cleaning up, either by closing accounts proactively or by exposing a close instruction users can call when they're done.

Closure reclaims the SOL. The owner program drains the account's lamports to a recipient, wipes the data, and marks the account so the runtime knows it's gone. The recipient is whoever the program designates. Often the user who originally paid. Sometimes the protocol treasury. Sometimes whoever called the close, as an incentive for cleanup work.

The three-step close

A safe close has three steps. Anchor's close = recipient constraint performs all three for you, but understanding what they do is worth more than the syntax.

The three steps of closing an account before: a normal account lamports: 2,039,280 (rent-exempt minimum) data: disc | field1 | field2 | ... (real bytes) step 1: transfer all lamports to recipient lamports: 0 (account is now below rent-exempt) recipient: +2,039,280 if skipped: account stays paid for forever, SOL is stranded step 2: zero the data buffer data: 0x00 0x00 0x00 ... (every byte wiped) old fields are gone, no readable state remains if skipped: stale fields readable if account is funded again step 3: overwrite discriminator with CLOSED_ACCOUNT_DISCRIMINATOR data[0..8]: [255, 255, 255, 255, 255, 255, 255, 255] Anchor will refuse to deserialize this account as anything if skipped: revival attack — caller funds it, original handler runs on stale state

Step 1 returns the rent. Every Solana account holds enough SOL to be rent-exempt, and at close time that SOL is sent somewhere. The "recipient" in close = recipient is the account that receives it. Until the rent drains below the exempt threshold, the runtime keeps the account alive on chain. Skip this step and you've leaked rent into a dead account, exactly the situation closure is meant to prevent.

Step 2 zeros the data. Stale fields are a footgun the closer needs to handle. Even after the lamports are gone, the byte buffer still contains the old struct's contents until the runtime garbage-collects the account at the end of the transaction. If anything in the rest of the transaction reads that account's data, it sees the old state.

Step 3 is the one most people learn about the hard way. The first 8 bytes of every Anchor account are a discriminator, a hash identifying which struct type the bytes represent. After steps 1 and 2, the account's data is all zeros. If an attacker funds the account back to rent-exempt in a separate transaction, since anyone can transfer lamports to any pubkey, the runtime keeps the account alive. The discriminator is still gone, but the old discriminator hash could be re-attached by the program itself if someone calls a path that doesn't check carefully. To eliminate this risk entirely, Anchor writes a special "closed" discriminator of all 0xFF bytes. Any subsequent attempt to deserialize the account as its original type fails immediately.

The historical name for the attack this prevents is the "revival attack." Early Anchor versions would close an account without writing the closed-account discriminator. An attacker could re-fund the account in a follow-up transaction, then call an instruction that re-initialized the donor_record assuming it was fresh, and slip in adversarial values. The closed discriminator pattern killed that class of attack. When you use the close = recipient constraint, all three steps happen for free.

Using the close constraint

The mechanical syntax is short:

rust
#[derive(Accounts)]
pub struct CloseDonorRecord<'info> {
    #[account(
        mut,
        close = donor,
        seeds = [b"donor", donor.key().as_ref()],
        bump = donor_record.bump,
        has_one = donor,
    )]
    pub donor_record: Account<'info, DonorRecord>,

    #[account(mut)]
    pub donor: Signer<'info>,
}

pub fn close_donor_record(_ctx: Context<CloseDonorRecord>) -> Result<()> {
    // The close constraint does all the work.
    // Logic here would run before close, if you needed any.
    Ok(())
}

Three things to notice. The close = donor sends the rent SOL back to the donor's wallet. The has_one = donor constraint enforces that the signer is the same donor whose pubkey is stored on the record, blocking a stranger from closing someone else's account. The handler body is essentially empty because the constraint does everything. If you need to do work before the close, such as recording an event, checking invariants, or returning leftover token balances from a vault PDA, put it in the handler body before Ok(()). The close happens after the handler returns.

Where the rent goes

The recipient of a close is a design decision with real implications. Three common patterns show up in production programs.

Refund to the user is the default for accounts a user created and abandoned. A donation record after the user has withdrawn. A subscription account after the subscription expired. The user paid for the account at init, the user gets the SOL back on close. This is the friendliest UX: the user can recoup their costs whenever they're done.

Refund to the protocol is for accounts the protocol manages on the user's behalf. Sometimes a protocol pays the rent up front to subsidize UX, and on close the rent goes back to the treasury rather than to the user. The user pays nothing either way, so they don't notice the difference. The protocol does, because rent recovery is a real line item for any program operating at scale.

Refund to the caller is the cleanup-incentive pattern. If anyone can call the close instruction once some condition is met, say an expired auction or a fully matched order, and the rent SOL goes to the caller, you get cleanup for free. Liquidators, keeper bots, and arbitrageurs will happily call your close function for the few thousand lamports of reclaimed rent. This pattern only works when the account is genuinely safe to close from any caller. The access-control logic has to be inside the close instruction's preconditions.

The choice depends on who paid, who benefits from cleanup, and who you want to incentivize. For most user-facing accounts, refund-to-user is right. For protocol-internal accounts, treasury. For accounts that should be closed by anyone after a deadline, caller.

Realloc: when accounts grow

The opposite operation: an account exists, it works, but the data inside it needs more room. Realloc resizes the buffer in place, paying or refunding the rent delta, without forcing you to close and recreate.

Growing an account with realloc before: 100-byte account, 50 bytes used data buffer (100 bytes): used (50 bytes) empty (50 bytes) realloc = 200 after: 200-byte account, original data preserved data buffer (200 bytes): used (50 bytes) empty (50 bytes) new bytes (100 bytes) zeroed if realloc::zero = true old contents: preserved byte-for-byte at the start of the buffer new bytes: zeroed (with realloc::zero = true) or untouched (= false) payer: transfers the rent delta to keep the account exempt the rent delta the payer covers rent_exempt(200 bytes) - rent_exempt(100 bytes) = roughly 700,000 lamports shrinking is the mirror: the account keeps the surplus, no refund happens hard cap: 10 KB growth per instruction, split larger growth across multiple calls

Growing pays the rent delta. Shrinking does not refund. The account keeps any surplus lamports it had before, which means a 200-byte account shrunk to 100 bytes ends up sitting at the 200-byte rent-exempt minimum, more lamports than the 100-byte minimum requires. That's harmless from the chain's perspective. It just means you don't recover SOL by shrinking. If you want the SOL back, you close the account.

The Anchor constraint:

rust
#[derive(Accounts)]
#[instruction(new_size: usize)]
pub struct GrowList<'info> {
    #[account(
        mut,
        realloc = 8 + new_size,
        realloc::payer = payer,
        realloc::zero = true,
        seeds = [b"list", list.owner.as_ref()],
        bump = list.bump,
    )]
    pub list: Account<'info, MessageList>,

    #[account(mut)]
    pub payer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

realloc = NEW_SIZE sets the new total byte count, discriminator included. realloc::payer names the signer who covers the rent delta when growing. realloc::zero = true tells the runtime to zero the new bytes. Set it to false only when you're going to overwrite those bytes anyway in the same instruction, and you want the small CU saving. Most of the time, true is the safe default.

The hard ceiling on a single realloc operation is 10 KB of growth per instruction. If you need an account to grow by 50 KB, you need five separate instructions, each adding up to 10 KB. The cap exists to keep transaction execution from doing unbounded work in one shot. For most use cases, growing by 1 KB or 2 KB per call is plenty, and bumping by smaller chunks is more idiomatic anyway.

Realloc, many PDAs, or close-and-recreate?

Realloc is one of three tools for handling data that doesn't fit in a single fixed account. It's worth knowing when to reach for each.

Realloc, many PDAs, or close-and-recreate? grow one account approach: realloc as needed good for: - bounded list that fits in 10 KB total - single thread of data per account - predictable growth by fixed chunks tradeoffs: payer covers rent delta each time; 10 KB hard ceiling per instruction. example: message board with a growing post list simple, single home many small PDAs approach: one PDA per item, indexed by seed good for: - unbounded growth - per-user data that scales per donor - close-on-exit refund pattern tradeoffs: discriminator on every account costs 8 bytes; many tx accounts to manage example: donations, votes, orders, positions the Solana default close and recreate approach: close old account, init fresh one good for: - end-of-lifecycle cleanup - reclaiming rent for the user - starting over with a fresh schema tradeoffs: data is gone after close; need 2 tx if user keeps using the program example: withdraw + close vesting record returns rent to user For most "this user has many items" cases, many small PDAs is the right call. Realloc is the exception.

A useful way to read this chart: if the data grows in lockstep with one entity, say one account holding one list of bounded entries, realloc fits. If the data grows because more entities show up, more donations, more orders, more positions, separate PDAs fit. If the data has an explicit end of life, the subscription expired or the vault drained or the position closed, close-and-recreate fits.

The Donor Tiers Vault from the first milestone is the canonical many-PDAs case. Each donation gets its own PDA. The donor record stays small. Nothing ever needs to realloc, because each new donation is a fresh account at its own address. The reason that design is preferred over one big account per donor is that there's no upper bound on donations a single donor might make. A Vec<Donation> inside one account would either need a low cap that rejects more than N donations, or grow forever via realloc. Both are worse than just giving each donation its own home.

Realloc earns its place when the bound is real and small. A message board where each board has up to a few hundred posts, capped at maybe 8 KB total, is fine to grow with realloc. A position-history log that tops out at 1 KB is fine. Anything that could plausibly need more than 10 KB total or might want to grow past the cap in one transaction needs a different design.

What you actually do day to day

For most accounts, you'll never need realloc or explicit close handling. The default Anchor account pattern is: pick a fixed size at init, store data in it, leave it. When users go inactive, their accounts sit there harmlessly. The protocol doesn't need to clean up unless rent recovery matters for your scale.

When you do need closure, reach for close = recipient. Pick the recipient deliberately based on who paid and who benefits from cleanup. Add a has_one constraint or equivalent auth check to gate who can close. Anchor handles the three-step process for you, including the closed-account discriminator that prevents revival attacks.

When you do need realloc, the questions to answer are: how much growth, how often, who pays? Set a sensible growth chunk of 1 KB or 2 KB, have the user pay the delta, and use realloc::zero = true unless you're certain you don't need the safety. Watch the 10 KB cap. If you find yourself fighting it, your design probably wants many small PDAs instead.

The accounts your program ships with at v1 are the accounts that determine its operating cost forever. Layout the storage with closure and realloc in mind from the start, and these become small tools you reach for occasionally. Layout it carelessly, and these become migration headaches you fight repeatedly.