Private, panic-free callbacks
NEAR callback security: mark callbacks #[private], read the promise result correctly, never panic in the undo path, and reserve enough gas for it.
Advanced8 min read3-question check
A callback is an ordinary public method of your contract — the runtime has to be able to call it. That means anyone else can call it too, unless you stop them. And because the callback is where you undo optimistic state changes, it is also the one place that must never fail.
This lesson covers the three ways callbacks go wrong: they are callable by strangers, they misread the promise result, or they panic or run out of gas before the undo happens.
The bug#
Rust
#[near]
impl Staking {
pub fn unstake(&mut self, amount: U128) -> Promise {
let user = env::predecessor_account_id();
let staked = self.staked.get(&user).copied().unwrap_or(0);
require!(staked >= amount.0, "not enough staked");
self.staked.insert(user.clone(), staked - amount.0);
ext_pool::ext(self.pool.clone())
.with_static_gas(Gas::from_tgas(30))
.unstake(amount)
.then(Self::ext(env::current_account_id())
.with_static_gas(Gas::from_tgas(10))
.unstake_callback(user, amount))
}
// BUG: no #[private]. Any account can call this method.
pub fn unstake_callback(
&mut self,
user: AccountId,
amount: U128,
#[callback_result] result: Result<(), PromiseError>,
) {
if result.is_err() {
let staked = self.staked.get(&user).copied().unwrap_or(0);
self.staked.insert(user, staked + amount.0);
}
}
}Why it happens on NEAR#
The fix#
Rust
const GAS_FOR_UNSTAKE: Gas = Gas::from_tgas(30);
const GAS_FOR_CALLBACK: Gas = Gas::from_tgas(10); // enough for the undo path
#[near]
impl Staking {
pub fn unstake(&mut self, amount: U128) -> Promise {
let user = env::predecessor_account_id();
let staked = self.staked.get(&user).copied().unwrap_or(0);
require!(staked >= amount.0, "not enough staked");
self.staked.insert(user.clone(), staked - amount.0);
ext_pool::ext(self.pool.clone())
.with_static_gas(GAS_FOR_UNSTAKE)
.unstake(amount)
.then(Self::ext(env::current_account_id())
.with_static_gas(GAS_FOR_CALLBACK)
.unstake_callback(user, amount))
}
// #[private] panics unless predecessor_account_id == current_account_id,
// so only a promise scheduled by this contract can reach the body.
#[private]
pub fn unstake_callback(
&mut self,
user: AccountId,
amount: U128,
#[callback_result] result: Result<(), PromiseError>,
) -> bool {
match result {
Ok(()) => true,
Err(_) => {
// undo with no unwraps, no external calls, no heavy loops
let staked = self.staked.get(&user).copied().unwrap_or(0);
self.staked.insert(user, staked.saturating_add(amount.0));
false
}
}
}
}Check yourself
3 questions · progress saved in this browser