Near Learn

Refunds and failure-safe payouts

Let backers pull refunds from failed NEAR campaigns: deduct first, transfer, then restore the pledge in a #[private] callback if the transfer fails.

Intermediate9 min read3-question check

When a campaign misses its goal, every backer should get their pledge back. This milestone adds refund, and uses it to learn the most important pattern in NEAR contracts: change state first, make the call, and repair state in a callback if the call failed. Then we retrofit the same safety net onto claim.

Pull, don’t push#

We do not loop over all backers and refund them in one call. A campaign can have thousands of backers, and one call has a gas limit — a loop that works with ten backers would fail with ten thousand, leaving everyone stuck. Instead each backer pulls their own refund. Each call does a constant amount of work, no matter how big the campaign is.

A pull refund on both chains
Solidity
function refund(uint256 id) external {
    Campaign storage c = campaigns[id];
    require(block.timestamp >= c.deadline, "running");
    require(c.raised < c.goal, "goal reached");

    uint256 amount = pledges[id][msg.sender];
    require(amount > 0, "nothing to refund");
    pledges[id][msg.sender] = 0;            // deduct first

    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "transfer failed");         // reverts the deduction too
}
Rust
pub fn refund(&mut self, campaign_id: CampaignId) -> Promise {
    let backer = env::predecessor_account_id();
    let campaign = self.campaign(campaign_id);
    require!(env::block_timestamp_ms() >= campaign.deadline_ms, "campaign is still running");
    require!(campaign.raised < campaign.goal, "goal was reached: no refunds");

    let key = (campaign_id, backer.clone());
    let amount = self.pledges.get(&key).copied().unwrap_or(0);
    require!(amount > 0, "nothing to refund");
    self.pledges.insert(key, 0); // deduct first

    Promise::new(backer.clone())
        .transfer(NearToken::from_yoctonear(amount))
        .then(
            Self::ext(env::current_account_id())
                .with_static_gas(GAS_CALLBACK)
                .on_refund(campaign_id, backer, U128(amount)),
        )
}
Coming from EVM

The Solidity version gets rollback for free: if the transfer fails, require(ok) reverts the deduction. On NEAR the deduction is already committed when the transfer runs, so we must restore it — that is what on_refund does.

We set the pledge to 0 rather than removing the entry, so the backer list from lesson 3 stays consistent. raised is left untouched: it records what the campaign raised, and the “goal missed” check must not change as refunds go out.

The callbacks#

Add both callbacks to the #[near] impl block, plus a gas constant and the new imports
Rust
use near_sdk::{
    env, near, require, AccountId, BorshStorageKey, Gas, NearToken, PanicOnDefault, Promise,
    PromiseError,
};

const GAS_CALLBACK: Gas = Gas::from_tgas(10);

// inside #[near] impl Crowdfund
#[private]
pub fn on_refund(
    &mut self,
    campaign_id: CampaignId,
    backer: AccountId,
    amount: U128,
    #[callback_result] result: Result<(), PromiseError>,
) -> bool {
    if result.is_err() {
        // restore by adding to the CURRENT value, never a cached one
        let key = (campaign_id, backer);
        let current = self.pledges.get(&key).copied().unwrap_or(0);
        self.pledges.insert(key, current + amount.0);
        return false;
    }
    true
}

#[private]
pub fn on_claim(
    &mut self,
    campaign_id: CampaignId,
    #[callback_result] result: Result<(), PromiseError>,
) -> bool {
    if result.is_err() {
        self.campaign_mut(campaign_id).claimed = false; // let the creator try again
        return false;
    }
    true
}
  • `#[private]` makes the method panic unless the caller is the contract itself (predecessor == current_account_id). Without it, anyone could call on_refund with a fake failure and mint themselves a pledge. Every callback must be private.
  • `#[callback_result] result: Result<(), PromiseError>` reads the outcome of the promise this callback was chained to. A transfer returns no value, so the success type is (); a failure arrives as Err, it does not panic your callback.
  • The callback must not panic. If it panicked, the restore would not happen and the funds would be stranded exactly as before. Keep callbacks short, avoid unwrap on anything that can fail, and give them enough static gas (GAS_CALLBACK) for their few reads and writes.
  • The callback’s return value becomes the call’s result. Because refund returns the chain transfer.then(on_refund), the transaction now ends with true (paid) or false (restored). A failed payout is no longer a failed transaction, so clients should read the value.
  • Arguments carry the context. The callback runs in a later receipt, possibly after other transactions. We pass campaign_id, backer and amount explicitly instead of trusting state that may have changed.
Retrofit claim: chain on_claim after the payout
Rust
    campaign.claimed = true; // effects before the interaction
    let payout = Promise::new(campaign.creator.clone())
        .transfer(NearToken::from_yoctonear(campaign.raised));

    payout.then(
        Self::ext(env::current_account_id())
            .with_static_gas(GAS_CALLBACK)
            .on_claim(campaign_id),
    )
}
HintExercise: what goes wrong without the callback?

Imagine refund without .then(on_refund), and the backer’s account is deleted before they call it (someone with their key calls refund, then deletes the account in a later block — or the transfer fails for any other reason). The pledge is set to 0, the transfer fails, the NEAR stays in the contract and nobody can ever withdraw it. With the callback, the pledge comes back and the refund can be retried.

Check yourself

3 questions · progress saved in this browser

  1. 1.Why does refund set the pledge to 0 before scheduling the transfer?
  2. 2.What does #[private] on on_refund prevent?
  3. 3.Why do backers pull refunds one by one instead of the contract refunding everyone in a loop?