Near Learn

Reentrancy across receipts

NEAR reentrancy explained: other transactions run between a cross-contract call and its callback. Avoid double spends with deduct-first and lock patterns.

Advanced9 min read3-question check

NEAR has no synchronous reentrancy: a contract you call cannot jump back into your method while it is still running. But there is a wider gap instead. Between the moment you schedule a cross-contract call and the moment its callback runs, any number of other transactions can execute against your contract — including the same user calling the same method again.

Any state you read before the call may be stale by the time the callback runs. If your invariants only hold “after the callback”, an attacker gets to act in the window where they do not.

The bug#

The same mistake on both chains: effects after the interaction
Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Bank {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        // BUG: external call before the state update.
        // The receiver's fallback can re-enter withdraw()
        // and see the old balance again.
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
        balances[msg.sender] = 0;
    }
}
Rust
#[near]
impl Bank {
    pub fn withdraw(&mut self) -> Promise {
        let user = env::predecessor_account_id();
        let amount = self.balances.get(&user).copied().unwrap_or(0);
        require!(amount > 0, "nothing to withdraw");
        // BUG: "deduct after success" — the balance is only
        // zeroed in the callback. Until it runs, the user can
        // call withdraw() again and be paid the same amount.
        ext_ft::ext(self.token.clone())
            .with_attached_deposit(NearToken::from_yoctonear(1))
            .with_static_gas(Gas::from_tgas(10))
            .ft_transfer(user.clone(), U128(amount), None)
            .then(Self::ext(env::current_account_id())
                .with_static_gas(Gas::from_tgas(10))
                .on_withdraw(user))
    }

    #[private]
    pub fn on_withdraw(&mut self, user: AccountId,
        #[callback_result] r: Result<(), PromiseError>) {
        if r.is_ok() {
            self.balances.insert(user, 0);
        }
    }
}
Coming from EVM

Solidity: the attacker re-enters during the call. NEAR: the attacker simply sends a second transaction that lands between the call and the callback. Both are fixed the same way — update state before the interaction.

Why it happens on NEAR#

The fix#

Fixed — effects first, plus a per-user lock for the pending operation
Rust
use near_sdk::json_types::U128;
use near_sdk::store::{LookupMap, LookupSet};
use near_sdk::{env, ext_contract, near, require, AccountId, Gas, NearToken, PanicOnDefault, Promise, PromiseError};

#[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 Rewards {
    token: AccountId,
    rewards: LookupMap<AccountId, u128>,
    in_flight: LookupSet<AccountId>, // users with a claim awaiting its callback
}

#[near]
impl Rewards {
    pub fn claim(&mut self) -> Promise {
        let user = env::predecessor_account_id();
        require!(!self.in_flight.contains(&user), "claim already in progress");

        // effects BEFORE the interaction: the balance is gone right now
        let amount = self.rewards.remove(&user).unwrap_or(0);
        require!(amount > 0, "nothing to claim");
        self.in_flight.insert(user.clone());

        ext_ft::ext(self.token.clone())
            .with_attached_deposit(NearToken::from_yoctonear(1))
            .with_static_gas(Gas::from_tgas(10))
            .ft_transfer(user.clone(), U128(amount), None)
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(Gas::from_tgas(10))
                    .on_claim(user, U128(amount)),
            )
    }

    #[private]
    pub fn on_claim(
        &mut self,
        user: AccountId,
        amount: U128,
        #[callback_result] result: Result<(), PromiseError>,
    ) {
        self.in_flight.remove(&user); // always release the lock
        if result.is_err() {
            // transfer failed: put the reward back (add, don't overwrite —
            // new rewards may have accrued while we were waiting)
            let current = self.rewards.get(&user).copied().unwrap_or(0);
            self.rewards.insert(user, current + amount.0);
        }
    }
}

Check yourself

3 questions · progress saved in this browser

  1. 1.A contract checks a user’s balance, calls ft_transfer, and zeroes the balance in the callback on success. What can an attacker do?
  2. 2.How does reentrancy on NEAR differ from classic Solidity reentrancy?
  3. 3.In a callback that restores a balance after a failed transfer, which write is safest?