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.
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
}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)),
)
}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#
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 callon_refundwith 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 asErr, 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
unwrapon 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
refundreturns the chaintransfer.then(on_refund), the transaction now ends withtrue(paid) orfalse(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,backerandamountexplicitly instead of trusting state that may have changed.
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