Near Learn

Yield & resume (NEP-519)

Pause a NEAR receipt until an off-chain worker answers: Promise::new_yield, YieldId::resume, the 200-block timeout, and building a safe request/response oracle.

Advanced15 min read3-question check

Every callback so far waited for another contract. Yield execution, specified in NEP-519, lets a callback wait for something else: a later transaction that calls back into your contract and supplies the data. Your contract creates a yielded promise, stores its id, and returns. An off-chain worker sees the request, does its work, and calls a method that resumes the promise with a payload. The callback then runs with that payload, and its return value becomes the result of the user’s original transaction.

If nobody resumes, the protocol does it for you: after 200 blocks (about two minutes) the callback runs anyway, with a failed promise result. Your contract always gets to clean up.

Use caseWho resumes
MPC signing (NEAR chain signatures)The MPC nodes submit the signature they computed off-chain; the user’s sign transaction returns it
Oracles and AI agentsA worker (often in a TEE) answers a question or runs inference and submits the result
Cross-chain confirmationsA relayer submits a proof once the other chain has finalised
Where yield/resume is used

The flow in blocks#

Request/response through a yielded promise
Shell
block N        alice -> oracle.near::ask("q")         converted to a receipt
block N+1      ask: store request #7 with its yield id; return the yielded promise
               alice's transaction is now WAITING; nothing else is blocked
               ... worker polls the pending() view or an indexer, computes "42" ...
block N+k      worker -> oracle.near::answer(7, "42")   YieldId::resume(payload) -> Ok
shortly after  on_answer(7) runs with Ok("42")          alice's tx result: "42"

# nobody answers?
block N+1+200  on_answer(7) runs with Err(PromiseError) timeout: refund, clean up

A request/response oracle#

High-level API: Promise::new_yield (near-sdk 5.23+)
Rust
use near_sdk::store::IterableMap;
use near_sdk::{
    env, log, near, require, serde_json, AccountId, Gas, GasWeight, NearToken, PanicOnDefault,
    Promise, PromiseError, YieldId,
};

const GAS_FOR_ON_ANSWER: Gas = Gas::from_tgas(10);
const FEE: NearToken = NearToken::from_millinear(10);
const MAX_LEN: usize = 256;

#[near(serializers = [borsh, json])]
#[derive(Clone)]
pub struct Request {
    pub asker: AccountId,
    pub question: String,
    pub yield_id: YieldId, // JSON: base64 string, so workers can read it from a view
    pub fee: NearToken,
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Oracle {
    worker: AccountId,
    next_id: u64,
    requests: IterableMap<u64, Request>,
}

#[near]
impl Oracle {
    #[init]
    pub fn new(worker: AccountId) -> Self {
        Self { worker, next_id: 0, requests: IterableMap::new(b"r") }
    }

    #[payable]
    pub fn ask(&mut self, question: String) -> Promise {
        require!(question.len() <= MAX_LEN, "question too long");
        require!(env::attached_deposit() == FEE, "attach exactly 0.01 NEAR");
        let id = self.next_id;
        self.next_id += 1;

        // on_answer will be called with these args + the resume payload
        let args = serde_json::json!({ "request_id": id }).to_string();
        let (promise, yield_id) =
            Promise::new_yield("on_answer", args, GAS_FOR_ON_ANSWER, GasWeight(0));

        self.requests.insert(id, Request {
            asker: env::predecessor_account_id(),
            question,
            yield_id,
            fee: FEE,
        });
        // returning it makes on_answer's return value this transaction's result
        promise
    }

    /// For the worker: what is waiting?
    pub fn pending(&self, from_index: u32, limit: u32) -> Vec<(u64, Request)> {
        self.requests
            .iter()
            .skip(from_index as usize)
            .take(limit.min(50) as usize)
            .map(|(id, r)| (*id, r.clone()))
            .collect()
    }

    /// Called by the worker in its own transaction.
    pub fn answer(&mut self, request_id: u64, answer: String) {
        require!(env::predecessor_account_id() == self.worker, "only the worker may answer");
        require!(answer.len() <= MAX_LEN, "answer too long");
        let req = self
            .requests
            .get(&request_id)
            .unwrap_or_else(|| env::panic_str("unknown or finished request"));
        let payload = serde_json::to_vec(&answer).unwrap();
        req.yield_id
            .resume(payload)
            .unwrap_or_else(|_| env::panic_str("request is no longer waiting"));
    }

    /// Runs once: after resume, or after the 200-block timeout.
    #[private]
    pub fn on_answer(
        &mut self,
        request_id: u64,
        #[callback_result] answer: Result<String, PromiseError>,
    ) -> Option<String> {
        // always remove the request, on every branch
        let Some(req) = self.requests.remove(&request_id) else {
            return None;
        };
        match answer {
            Ok(a) => {
                Promise::new(self.worker.clone()).transfer(req.fee).detach();
                Some(a)
            }
            Err(_) => {
                log!("request {} timed out; refunding {}", request_id, req.asker);
                Promise::new(req.asker).transfer(req.fee).detach();
                None
            }
        }
    }
}

Rules that keep it safe#

  • Guard the resume method. answer is public. Anyone who can call it controls what your callback receives. Check the caller (or verify a signature over the payload) before resuming.
  • Only your contract can resume. Yield ids are local to the account that created them: resume must be called from a method of the same contract. A worker cannot resume directly; it calls your method, which resumes.
  • Handle the timeout branch. The callback receives Err(..) on timeout. Refund, release locks and delete the request there. The 200-block limit is a protocol parameter; you cannot extend it.
  • Treat `resume` as “first one wins”. It returns Err(ResumeError) once the yield is gone (resumed or timed out). The SDK docs also note that two resumes racing for the same id can both report success; only the first payload is delivered. Do not pay the worker in answer; pay in the callback, as above.
  • The callback runs on the asker’s gas. GAS_FOR_ON_ANSWER is reserved when ask runs, from the asker’s transaction. The worker’s transaction only pays for answer itself. Size the reserve for the worst branch, because a starved callback leaves the request and the fee stuck.
  • Bound everything. Cap question and answer lengths, charge a fee that covers the request’s storage, and paginate the pending view.
  • Clients wait longer than usual. The asker’s transaction is not finished until the callback runs, possibly two minutes later. An RPC call waiting for execution may time out first; poll the transaction status until it completes.
MoreThe low-level version (near-sdk 5.2+)

Before Promise::new_yield, contracts used the host functions directly. The resumption token is written to a register and read back as a 32-byte CryptoHash. You will still see this form in older code and in the official yield-resume example.

Rust
use near_sdk::{env, serde_json, CryptoHash, Gas, GasWeight};

const YIELD_REGISTER: u64 = 0;

// inside ask():
let index = env::promise_yield_create(
    "on_answer",
    serde_json::json!({ "request_id": id }).to_string().into_bytes(),
    Gas::from_tgas(10),
    GasWeight(0),
    YIELD_REGISTER,
);
let data_id: CryptoHash = env::read_register(YIELD_REGISTER)
    .expect("register is empty")
    .try_into()
    .expect("not 32 bytes");
// ... store data_id with the request ...
env::promise_return(index);

// inside answer():
let resumed: bool = env::promise_yield_resume(&data_id, serde_json::to_vec(&answer).unwrap());
near_sdk::require!(resumed, "request is no longer waiting");

near-sdk 5.29 adds Promise::new_yield_with_id with a caller-chosen UserYieldId (for example a hash of the request id), so the worker can derive the id instead of reading it from state. It needs nearcore 2.13 (protocol version 85) or later on the network you deploy to.

Check yourself

3 questions · progress saved in this browser

  1. 1.Nobody calls answer for request #7. What happens?
  2. 2.Why can’t the off-chain worker resume the yielded promise by calling a host function itself?
  3. 3.Whose gas pays for on_answer?