Near Learn

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#

Vulnerable — deducts, calls `ft_transfer`, never checks the result
Rust
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#

Fixed — deduct optimistically, restore in a private callback on failure
Rust
// 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 the Err branch 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

  1. 1.A contract deducts a user’s balance, then calls ft_transfer on a token contract with no callback. The transfer panics. What is the state afterwards?
  2. 2.When does a .then(...) callback run if the cross-contract call it is attached to fails?
  3. 3.Which design is correct for a vault withdrawal that sends FT tokens?