Callbacks don’t roll back the caller
NEAR smart contract security: why a failed cross-contract call does not roll back the caller’s state, and how to restore balances in a callback.
Advanced9 min read3-question check
On the EVM, if a nested call reverts, the whole transaction reverts with it — every storage write since the start is undone. On NEAR that safety net does not exist. A cross-contract call is a separate receipt that runs later, often in a later block, and the method that scheduled it has already finished and committed its state by then.
So if your method changes state and then calls another contract, and that call fails, your state change stays. Nobody undoes it for you. This is the most common way NEAR contracts lose user funds.
The bug#
use near_sdk::json_types::U128;
use near_sdk::store::LookupMap;
use near_sdk::{env, ext_contract, near, require, AccountId, Gas, NearToken, PanicOnDefault, Promise};
#[ext_contract(ext_ft)]
trait FungibleToken {
fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option<String>);
}
#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Vault {
token: AccountId,
balances: LookupMap<AccountId, u128>,
}
#[near]
impl Vault {
pub fn withdraw(&mut self, amount: U128) -> Promise {
let user = env::predecessor_account_id();
let balance = self.balances.get(&user).copied().unwrap_or(0);
require!(balance >= amount.0, "insufficient balance");
// committed as soon as this method returns
self.balances.insert(user.clone(), balance - amount.0);
// BUG: if ft_transfer panics (e.g. the user is not storage-registered
// on the token contract), the deduction above is NOT undone.
ext_ft::ext(self.token.clone())
.with_attached_deposit(NearToken::from_yoctonear(1))
.with_static_gas(Gas::from_tgas(10))
.ft_transfer(user, amount, None)
}
}Why it happens on NEAR#
The fix#
// same imports as above, plus:
use near_sdk::PromiseError;
// ... ext_ft and the Vault struct are unchanged
#[near]
impl Vault {
pub fn withdraw(&mut self, amount: U128) -> Promise {
let user = env::predecessor_account_id();
let balance = self.balances.get(&user).copied().unwrap_or(0);
require!(balance >= amount.0, "insufficient balance");
// 1. update state optimistically (before the call)
self.balances.insert(user.clone(), balance - amount.0);
// 2. send, then 3. check the outcome in a callback
ext_ft::ext(self.token.clone())
.with_attached_deposit(NearToken::from_yoctonear(1))
.with_static_gas(Gas::from_tgas(10))
.ft_transfer(user.clone(), amount, None)
.then(
Self::ext(env::current_account_id())
.with_static_gas(Gas::from_tgas(10))
.on_withdraw(user, amount),
)
}
#[private]
pub fn on_withdraw(
&mut self,
user: AccountId,
amount: U128,
#[callback_result] result: Result<(), PromiseError>,
) -> bool {
if result.is_err() {
// the transfer failed: give the balance back
let current = self.balances.get(&user).copied().unwrap_or(0);
self.balances.insert(user, current + amount.0);
return false;
}
true
}
}- Every cross-contract call that moves value has a
.then(...)callback. - The callback is
#[private]and handles theErrbranch explicitly. - Whatever you changed before the call has a matching undo in the callback.
- The callback has enough static gas to run its undo path (see Private callbacks).
Check yourself
3 questions · progress saved in this browser