Skip to content

token-fundraiser: no teardown for a failed campaign (vault + fundraiser rent stranded) #725

Open

Description

@MarkFeder

Summary

The token-fundraiser example has no teardown path for a failed campaign (one that expires without meeting its target). This affects both implementations:

Details

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 full vault.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.

Context

Raised during review of #708: #708 (comment)

Activity

  1. sehaha commented on Oct 28, 2026

    @sehaha

    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

    1. Failure-Path Teardown Instruction (teardown.rs):

      • Accounts: 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.
      • Preconditions:
        • Duration elapsed: duration <= ((current_time - time_started) / SECONDS_TO_DAYS) (FundraiserNotEnded).
        • 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.
    2. 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.
    3. 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:
        • Transfers unrecorded tokens into vault.
        • Executes teardown().
        • Asserts maker_ata received swept tokens.
        • Asserts client.getAccount(vault) === null and client.getAccount(fundraiser) === null.
        • Asserts makerLamportsAfter > makerLamportsBefore.
      • All 14 tests pass (14 passing (86ms)).
      • cargo fmt --check and cargo clippy -- -D warnings pass cleanly with 0 warnings.

    Could maintainers review this approach and apply the accepted label to this issue so that I can open the pull request? Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions