Near Learn

Deposits, refunds and transfers

Handle NEAR deposits safely: #[payable] only where needed, checked NearToken math, refunding overpayment, and transfers that fail for missing accounts.

Intermediate9 min read3-question check

Money enters a NEAR contract as the attached deposit of a function call (env::attached_deposit()) and leaves as a Transfer action in a new receipt. Both ends have sharp edges: accepting NEAR you didn’t mean to accept, keeping change you owe, doing arithmetic that wraps, and assuming an outgoing transfer cannot fail.

The bug#

Vulnerable — three money bugs in one contract
Rust
#[near]
impl Tickets {
    #[payable]
    pub fn buy_ticket(&mut self) {
        let paid = env::attached_deposit();
        // BUG 1: overpayment is silently kept by the contract
        require!(paid >= self.price, "not enough");
        self.tickets.insert(env::predecessor_account_id());
        self.proceeds = self.proceeds.saturating_add(self.price);
    }

    // BUG 2: #[payable] for no reason — NEAR attached by mistake is lost
    #[payable]
    pub fn set_nickname(&mut self, name: String) { /* ... */ }

    pub fn withdraw_proceeds(&mut self, to: AccountId) -> Promise {
        self.assert_owner();
        let amount = self.proceeds;
        self.proceeds = NearToken::from_yoctonear(0);
        // BUG 3: if `to` is a named account that doesn't exist, the transfer
        // fails and the NEAR bounces back to this contract — but `proceeds`
        // already says it is gone, so it is stranded.
        Promise::new(to).transfer(amount)
    }
}

Why it happens on NEAR#

The fix#

Fixed — exact accounting, refunds, and a callback on outgoing transfers
Rust
use near_sdk::store::LookupSet;
use near_sdk::{env, near, require, AccountId, Gas, NearToken, PanicOnDefault, Promise, PromiseError};

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Tickets {
    owner: AccountId,
    price: NearToken,
    proceeds: NearToken,
    tickets: LookupSet<AccountId>,
}

#[near]
impl Tickets {
    #[payable]
    pub fn buy_ticket(&mut self) {
        let buyer = env::predecessor_account_id();
        require!(!self.tickets.contains(&buyer), "already have a ticket");

        // checked math: panics with a clear message instead of wrapping
        let change = env::attached_deposit()
            .checked_sub(self.price)
            .unwrap_or_else(|| env::panic_str("attached deposit is below the ticket price"));

        self.tickets.insert(buyer.clone());
        self.proceeds = self.proceeds
            .checked_add(self.price)
            .unwrap_or_else(|| env::panic_str("proceeds overflow"));

        if change > NearToken::from_yoctonear(0) {
            Promise::new(buyer).transfer(change); // refund the overpayment
        }
    }

    pub fn set_nickname(&mut self, name: String) { /* not payable */ }

    pub fn withdraw_proceeds(&mut self, to: AccountId) -> Promise {
        require!(env::predecessor_account_id() == self.owner, "not owner");
        let amount = std::mem::replace(&mut self.proceeds, NearToken::from_yoctonear(0));
        require!(amount > NearToken::from_yoctonear(0), "nothing to withdraw");

        Promise::new(to).transfer(amount).then(
            Self::ext(env::current_account_id())
                .with_static_gas(Gas::from_tgas(5))
                .on_withdraw_proceeds(amount),
        )
    }

    #[private]
    pub fn on_withdraw_proceeds(
        &mut self,
        amount: NearToken,
        #[callback_result] result: Result<(), PromiseError>,
    ) {
        if result.is_err() {
            // the NEAR came back to us; put it back on the books
            self.proceeds = self.proceeds.saturating_add(amount);
        }
    }
}

Check yourself

3 questions · progress saved in this browser

  1. 1.A user accidentally attaches 5 NEAR to a method that is not marked #[payable]. What happens?
  2. 2.A contract zeroes an internal balance, then transfers it to bob-typo.near, a named account that does not exist. What happens to the NEAR?
  3. 3.A ticket costs 2 NEAR and the user attaches 3 NEAR. What should a well-written buy_ticket do?