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#
| Tool | Type you get | On failure | Use when |
|---|---|---|---|
#[callback_result] r: Result<T, PromiseError> | Decoded T or an error | You get Err(..) and decide | Default choice for anything that moves value or changed state |
#[callback_unwrap] v: T | Decoded T | The callback panics | Pure 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() | bool | false | You only care whether a single call worked |
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.
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_priceafter it, feeding you any “price” it likes (see Private callbacks).
What the original caller sees#
| Your method returns | Final result seen by the client or calling contract |
|---|---|
() after detaching a promise | null 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 |
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