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#
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#
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