Near Learn

Callbacks in depth

NEAR callbacks in depth: #[callback_result] vs #[callback_unwrap], promise_results_count, passing context through, PromiseOrValue, and what the caller sees.

Intermediate13 min read3-question check

A callback is an ordinary public method on your contract that you schedule with .then(...). It receives two kinds of input. Its arguments are the values you passed when you scheduled it: they are serialised into the callback receipt at that moment, like a snapshot. Its promise results are the outputs of the receipts it waited for, delivered by the runtime through data receipts. The SDK attributes #[callback_result] and #[callback_unwrap] turn promise results into typed parameters; everything else is a normal argument.

Four ways to read a result#

ToolType you getOn failureUse when
#[callback_result] r: Result<T, PromiseError>Decoded T or an errorYou get Err(..) and decideDefault choice for anything that moves value or changed state
#[callback_unwrap] v: TDecoded TThe callback panicsPure reads where failure needs no cleanup
env::promise_result_checked(i, max_len)Result<Vec<u8>, PromiseError>Err(PromiseError::Failed)Variable number of results, untrusted callees, custom decoding
near_sdk::is_promise_success()boolfalseYou only care whether a single call worked
Read a price from an oracle, keep context in arguments, handle both outcomes
Rust
use near_sdk::json_types::U128;
use near_sdk::{env, ext_contract, log, near, AccountId, Gas, PanicOnDefault, Promise, PromiseError};

#[ext_contract(ext_oracle)]
pub trait Oracle {
    fn get_price(&self, asset: String) -> U128;
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Lender {
    oracle: AccountId,
    last_price: Option<u128>,
}

#[near]
impl Lender {
    #[init]
    pub fn new(oracle: AccountId) -> Self {
        Self { oracle, last_price: None }
    }

    pub fn refresh(&mut self, asset: String) -> Promise {
        let requested_at = env::block_height();
        ext_oracle::ext(self.oracle.clone())
            .with_static_gas(Gas::from_tgas(5))
            .get_price(asset.clone())
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(Gas::from_tgas(5))
                    // context travels as normal arguments
                    .on_price(asset, requested_at),
            )
    }

    #[private]
    pub fn on_price(
        &mut self,
        asset: String,
        requested_at: u64,
        #[callback_result] price: Result<U128, PromiseError>,
    ) -> Option<U128> {
        match price {
            Ok(p) => {
                log!("{} = {} (asked at block {}, answered at {})",
                    asset, p.0, requested_at, env::block_height());
                self.last_price = Some(p.0);
                Some(p)
            }
            Err(_) => {
                log!("oracle failed for {}; keeping the previous price", asset);
                None
            }
        }
    }
}

Reading results by hand#

The attributes are thin wrappers over two host functions. env::promise_results_count() says how many results this callback received (one per promise it waited for, in order). env::promise_result_checked(i, max_len) returns result i as raw bytes, refusing results longer than max_len with PromiseError::TooLong. Recent 5.x releases deprecate the unbounded env::promise_result(i) for exactly that reason: a hostile callee can return megabytes of data and make your callback run out of gas just reading it. The attributes take the same bound as an option, for example #[callback_result(max_bytes = 64)]; without it they accept a result of any length.

The same callback without attributes, with a bound on the result size
Rust
use near_sdk::{env, log, near, require, serde_json, PromiseError};
use near_sdk::json_types::U128;

const MAX_PRICE_LEN: usize = 64; // a JSON string like "123456789" fits easily

#[near]
impl Lender {
    #[private]
    pub fn on_price_raw(&mut self, asset: String) -> Option<U128> {
        require!(env::promise_results_count() == 1, "expected exactly one result");
        match env::promise_result_checked(0, MAX_PRICE_LEN) {
            Ok(bytes) => match serde_json::from_slice::<U128>(&bytes) {
                Ok(p) => {
                    self.last_price = Some(p.0);
                    Some(p)
                }
                Err(_) => {
                    log!("{}: oracle returned something that is not a U128", asset);
                    None
                }
            },
            Err(PromiseError::TooLong(len)) => {
                log!("{}: result of {} bytes rejected", asset, len);
                None
            }
            // PromiseError is #[non_exhaustive]: always keep a wildcard arm
            Err(_) => None,
        }
    }
}

Context: what to pass, what to re-read#

  • Pass identities and amounts the callback needs to undo or finish the operation: the user, the amount, an operation id. They are captured when you schedule, so they describe this exact operation even if state changed since.
  • Re-read live state that other transactions may have touched in between, such as balances. Add or subtract deltas against fresh values; never write back a number you cached before the call (see Reentrancy across receipts).
  • Keep arguments small. They are stored in the callback receipt and cost gas to serialise and parse. Pass an id and look the rest up rather than shipping a whole struct.
  • Mark the callback `#[private]`. It is a public method; without the check, an attacker’s contract can schedule a call of its own and chain your on_price after it, feeding you any “price” it likes (see Private callbacks).

What the original caller sees#

Your method returnsFinal result seen by the client or calling contract
() after detaching a promisenull right away. The call’s outcome is visible only in its own receipt.
Promise (a bare call)The callee’s return value, or a failure if the callee failed
Promise (call .then(callback))The callback’s return value. A handled failure shows up as success with whatever your callback returned.
PromiseOrValue<T>Either a value now, or the result of the promise, decided at run time
The method’s return type decides the transaction’s final result
PromiseOrValue: answer from cache when fresh, call the oracle otherwise
Rust
use near_sdk::PromiseOrValue;

const MAX_AGE_BLOCKS: u64 = 100;

#[near]
impl Lender {
    pub fn price(&mut self, asset: String) -> PromiseOrValue<Option<U128>> {
        if let (Some(p), Some(at)) = (self.last_price, self.last_price_block) {
            if env::block_height() - at <= MAX_AGE_BLOCKS {
                return PromiseOrValue::Value(Some(U128(p)));
            }
        }
        // stale: fall through to an async refresh; on_price returns Option<U128>
        PromiseOrValue::Promise(self.refresh(asset))
    }
}
// (assumes the state also tracks last_price_block, set in on_price)

Both arms must produce the same JSON type for the caller, which is why on_price returns Option<U128> too. The same pattern appears in NEP-141: ft_on_transfer returns PromiseOrValue<U128>, the amount of tokens to give back, so a receiver can answer at once or after its own async work.

Check yourself

3 questions · progress saved in this browser

  1. 1.A method debits a user, then calls a token contract with a callback that takes #[callback_unwrap] r: (). The token call fails. What happens?
  2. 2.Why prefer env::promise_result_checked(i, max_len) over the deprecated env::promise_result(i)?
  3. 3.A method returns call.then(callback). The call fails, and the callback handles it and returns false. What does the client see as the transaction result?