The README allows refunds only "if deadlines are reached and goals aren't met", and the Actors section repeats both conditions. A campaign is meant to end in exactly one of two ways: it succeeds and the creator withdraws, or it fails and the contributors get refunds.
refund() has one eligibility check (L69), and it is incomplete in two ways.
It never reads fund.goal or fund.amount_raised, so a campaign that met its goal can be refunded after its deadline.
The deadline != 0 && short-circuit lets a refund through whenever no deadline is set. fund_create sets deadline to 0 (L17), and it stays 0 until the creator calls set_deadline.
The two time checks also overlap at the boundary. contribute() rejects only when deadline < now (L29), while refund() accepts when deadline <= now, so at now == deadline both are open. In the deployed code these refunds pay 0 lamports, because contribute() never records contribution.amount (reported separately). This finding is the missing eligibility condition, which remains after that one-line fix.
Likelihood:
In the deployed code no SOL moves through this path, because refunds pay the never-recorded contribution.amount. refund() already returns Ok on a goal-met fund and on a fund without a deadline today (the live test). It will pay real SOL as soon as contribute() records amounts. This dependency is why Likelihood is Low.
Once refunds pay, any contributor can trigger it with an ordinary call. That works after the deadline of a campaign that met its goal. It also works at any time on a campaign whose creator has not called set_deadline, which is the state every campaign starts in.
Impact:
Contributors can claw back SOL from a campaign that succeeded, taking the raise away from its creator. In the PoC, all 120 SOL of a campaign with a 100 SOL goal are refunded and the creator receives nothing. After the first refund the creator's withdraw() also reverts, because refund() does not reduce amount_raised. That revert is a combined effect with the refund accounting, not part of this root cause.
Campaigns without a deadline give no commitment: any contributor can pull their SOL mid-campaign. After a deadline, refund and withdraw are open over the same lamports at once, so transaction order alone decides whether the creator or the contributors get paid. The PoC runs both orderings.
The file has two layers of tests. Both run the unmodified program.
Live test. This runs on unmodified state. refund() returns Ok in two cases:
a fund whose deadline was never set (goal 100 SOL, 5 SOL raised, mid-campaign);
a fund that met its goal (10 SOL goal, 12 SOL raised), called after its deadline.
Both refunds move 0 lamports because of the separate contribution.amount bug. A baseline fund with a future deadline rejects the same refund with DeadlineNotReached (6002).
Latent tests. These show what the gate lets through once refunds pay. Before each test starts, ProgramTest::add_account writes each contributor's Contribution PDA with exactly the record that the one-line contribute() fix would leave: {contributor, fund, amount}. The record is Anchor-serialized with its discriminator, 80 bytes, rent-exempt and owned by the program. contribute() still runs for real, moves the lamports and updates amount_raised. The tests show:
a 120 SOL raise against a 100 SOL goal is refunded in full after the deadline, while the creator's withdraw() reverts with InsufficientFunds;
when the creator transacts first, the creator takes 120 SOL and Carol's refund fails, so order decides the outcome;
on a fund without a deadline, a contributor refunds 2 SOL in the same block as the contribution, while an identical fund with a deadline rejects the refund;
at now == deadline both contribute() and refund() succeed. At deadline - 1 the refund is rejected (6002), and at deadline + 1 the contribution is rejected (6001).
Run from the repository root. Everything before the cd line is a one-time setup; save the file above as programs/rustfund/tests/poc_RF-03.rs before running the last line:
Result: test result: ok. 5 passed; 0 failed. Key log lines:
Allow refunds only when a deadline exists, it has passed and the goal was missed. Close contributions at the deadline second so the two windows do not overlap.
## Description the refund mechanism only verifies that the current time has passed the campaign deadline, without checking whether the campaign has failed to meet its funding goal.This oversight may result in refunds being issued even if the campaign was, in principle, successful, potentially undermining the trust and financial integrity of the platform.   ## Vulnerability Details The refund function in the contract is designed to return funds to contributors if a campaign fails. However, it only checks whether the campaign deadline has been reached (or passed) before allowing a refund, without verifying if the campaign's funding goal was met. In other words, the function solely relies on a time-based condition and does not incorporate the additional logic required to determine if a campaign has been unsuccessful. **Code Analysis:**\ The refund function contains the following check: ```Rust if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ``` This condition ensures that refunds are only triggered after the deadline has passed. However, there is no subsequent verification that compares `fund.amount_raised` to the `fund.goal` to determine whether the campaign failed to meet its funding target. As a result, even if the campaign has met or exceeded its goal, contributors could potentially request refunds simply because the deadline has passed. ## proof Of Concept ```typescript it("Allows refund on a successful campaign due to missing goal check", async () => { // Define campaign parameters with a near-future deadline (5 seconds from now) const fundName = "refund flaw"; const description = "Test for refund vulnerability on a successful campaign"; const goal = new anchor.BN(1000000000); // 1 SOL goal // Set deadline to 5 seconds from now const deadline = new anchor.BN(Math.floor(Date.now() / 1000) + 5); // Generate PDA for the fund using the campaign name and creator's public key let [fundPDA, fundBump] = await PublicKey.findProgramAddress( [Buffer.from(fundName), creator.publicKey.toBuffer()], program.programId ); // Create the fund campaign await program.methods .fundCreate(fundName, description, goal) .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); // Set the campaign deadline await program.methods .setDeadline(deadline) .accounts({ fund: fundPDA, creator: creator.publicKey, }) .rpc(); // Airdrop lamports to otherUser so they can contribute const airdropSig = await provider.connection.requestAirdrop( otherUser.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL // e.g., 2 SOL ); await provider.connection.confirmTransaction(airdropSig); // Generate PDA for the contribution account using fund's PDA and otherUser's public key let [contributionPDA, contributionBump] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), otherUser.publicKey.toBuffer()], program.programId ); // otherUser contributes 1 SOL, meeting the campaign goal const contributionAmount = new anchor.BN(1000000000); // 1 SOL await program.methods .contribute(contributionAmount) .accounts({ fund: fundPDA, contributor: otherUser.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); // Verify the campaign is successful by checking that amountRaised >= goal let fundBeforeDeadline = await program.account.fund.fetch(fundPDA); expect(fundBeforeDeadline.amountRaised.gte(goal)).to.be.true; // Wait until after the deadline has passed await new Promise((resolve) => setTimeout(resolve, 6000)); // otherUser calls refund despite the campaign being successful // (a correct implementation should disallow this refund) let refundTxSucceeded = true; try { await program.methods .refund() .accounts({ fund: fundPDA, contribution: contributionPDA, contributor: otherUser.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); } catch (err) { refundTxSucceeded = false; } // The vulnerability: refund call is erroneously allowed even though the campaign met its goal. expect(refundTxSucceeded).to.be.true; // check the contributor's balance change to further demonstrate the refund was processed. const balanceAfterRefund = await provider.connection.getBalance( otherUser.publicKey ); console.log("Contributor balance after refund:", balanceAfterRefund); }); ``` ## Impact - **Financial Discrepancies:**\ The improper refund mechanism result in successful campaigns losing funds that were meant to be retained by the campaign creator, leading to financial imbalances within the contract. - **Erosion of Trust:**\ Contributors and creators rely on the refund logic to be fair and accurate. The absence of a funding goal check in the refund function erode trust in the platform, as users could experience unexpected fund reversals or disputes over campaign success. - **Operational Risks:**\ Campaigns that meet their funding goals still be subject to refund requests, creating operational inefficiencies and potential disputes between creators and contributors. This undermines the intended crowdfunding model and could deter future participation.   ## Recommendations Update the refund function to include a check that verifies whether the campaign's funding goal has been met. Refunds should only be processed if both the deadline has passed and the `amount_raised` is below the `goal`.    ```Solidity if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } if ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal { return Err(ErrorCode::CampaignSuccessful.into()); } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.