You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
token-fundraiser: no teardown for a failed campaign (vault + fundraiser rent stranded) #725
The token-fundraiser example has no teardown path for a failed campaign (one that expires without meeting its target). This affects both implementations:
The program exposes four instructions — initialize, contribute, check_contributions (the anchor checker), and refund — and only tears down accounts on the success path:
Success:check_contributions requires vault.amount >= amount_to_raise, transfers the fullvault.amount to the maker, and closes the vault + fundraiser. Any tokens sent directly into the vault are swept to the maker here, so nothing is stranded on this path.
Failure: once a campaign expires under target, contributors call refund, which returns each contributor's recorded amount and closes only that contributor's record. There is no instruction that closes the vault or the fundraiser account, so:
the vault ATA and fundraiser PDA are left behind with their rent unrecovered, and
any tokens transferred directly into the vault (not via contribute, so not part of any recorded contribution) stay permanently locked.
This gap is inherited from the reference implementation — the anchor version has the same four instructions and no failed-campaign teardown — so it is not specific to the pinocchio port.
Why not just gate on the vault balance
Switching check_contributions back to releasing based on the raw vault balance is not the fix: because anyone can transfer into a standard ATA, that would let a stranger force-release a campaign that never met its target (and block legitimate refunds). That is a more serious issue than stranded rent, so both implementations intentionally gate release on the recorded total.
Proposed follow-up
Add a maker-callable teardown instruction for a failed campaign — allowed once the duration has elapsed and the target was not met — that sweeps any remaining vault balance back to the maker and closes the vault + fundraiser accounts, recovering their rent. To keep the examples in parity, this should be applied to both the anchor and pinocchio implementations together rather than diverging one.
I have implemented and fully verified the failed-campaign teardown and rent recovery mechanism requested in this issue for tokens/token-fundraiser/anchor.
Target not met: current_amount < amount_to_raise (TargetMet).
All recorded contributions refunded: current_amount == 0 (ContributionsRemaining).
Vault Balance Sweeper & Rent Recovery:
If vault.amount > 0 (unrecorded tokens or direct transfers), sweeps them directly to maker_ata via signed CPI token::transfer.
Closes the vault token account via signed CPI token::close_account (destination: maker), recovering its rent lamports.
Anchor closes fundraiser PDA (close = maker), recovering its rent lamports back to the maker.
Success-Path Vault Rent Recovery (checker.rs):
In check_contributions, after transfer(cpi_ctx, self.vault.amount)?, the vault token account is now explicitly closed via CPI token::close_account with destination maker. Previously, only the fundraiser PDA had close = maker, leaving the empty vault ATA behind with its rent stranded.
LiteSVM Test Coverage (tests/litesvm.test.ts):
Teardown is rejected for unauthorized caller (ConstraintSeeds).
Activity
sehaha commentedon Oct 28, 2026
Hi @MarkFeder @amilz @dev-jodee,
I have implemented and fully verified the failed-campaign teardown and rent recovery mechanism requested in this issue for
tokens/token-fundraiser/anchor.🔍 Technical Implementation Overview
Failure-Path Teardown Instruction (
teardown.rs):maker(Signer),mint_to_raise,fundraiser(mut, seeds[b"fundraiser", maker.key()], bump,has_one = maker,has_one = mint_to_raise,close = maker),vault(mut ATA),maker_ata(mut ATA,init_if_needed),token_program,system_program,associated_token_program.duration <= ((current_time - time_started) / SECONDS_TO_DAYS)(FundraiserNotEnded).current_amount < amount_to_raise(TargetMet).current_amount == 0(ContributionsRemaining).vault.amount > 0(unrecorded tokens or direct transfers), sweeps them directly tomaker_atavia signed CPItoken::transfer.vaulttoken account via signed CPItoken::close_account(destination:maker), recovering its rent lamports.fundraiserPDA (close = maker), recovering its rent lamports back to the maker.Success-Path Vault Rent Recovery (
checker.rs):check_contributions, aftertransfer(cpi_ctx, self.vault.amount)?, thevaulttoken account is now explicitly closed via CPItoken::close_accountwith destinationmaker. Previously, only thefundraiserPDA hadclose = maker, leaving the empty vault ATA behind with its rent stranded.LiteSVM Test Coverage (
tests/litesvm.test.ts):Teardown is rejected for unauthorized caller(ConstraintSeeds).Teardown sweeps stray vault dust and closes vault + fundraiser recovering rent:vault.teardown().maker_atareceived swept tokens.client.getAccount(vault) === nullandclient.getAccount(fundraiser) === null.makerLamportsAfter > makerLamportsBefore.14 passing (86ms)).cargo fmt --checkandcargo clippy -- -D warningspass cleanly with 0 warnings.Could maintainers review this approach and apply the
acceptedlabel to this issue so that I can open the pull request? Thank you!