Near Learn

Joins and fan-out

Call several NEAR contracts in parallel with Promise::and, read every result in one callback, and handle partial failure without losing funds or state.

Advanced14 min read3-question check

a.and(b) merges two promises into a joint promise. The calls themselves are independent receipts that run concurrently, possibly on different shards. A callback chained after the joint, a.and(b).then(cb), waits until every member has finished and receives one promise result per member, in the order they were joined.

There is no “all or nothing” across the members. A joint is a barrier, not a transaction: some calls can succeed while others fail, and your callback has to sort that out.

Fan-out: ask N oracles, keep the median#

Query a bounded set of oracles in parallel and require a quorum of answers
Rust
use near_sdk::json_types::U128;
use near_sdk::{
    env, ext_contract, log, near, require, serde_json, AccountId, Gas, PanicOnDefault, Promise,
};

const MAX_ORACLES: usize = 7;
const GAS_PER_ORACLE: Gas = Gas::from_tgas(5);
const GAS_FOR_AGGREGATE: Gas = Gas::from_tgas(10);
const MAX_RESULT_LEN: usize = 64;

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

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Feed {
    oracles: Vec<AccountId>, // small and bounded: lives in the root state
    quorum: u32,
    price: Option<u128>,
    updated_at: u64,
}

#[near]
impl Feed {
    #[init]
    pub fn new(oracles: Vec<AccountId>, quorum: u32) -> Self {
        require!(!oracles.is_empty() && oracles.len() <= MAX_ORACLES, "1 to 7 oracles");
        require!(quorum >= 1 && quorum as usize <= oracles.len(), "bad quorum");
        Self { oracles, quorum, price: None, updated_at: 0 }
    }

    pub fn refresh(&mut self) -> Promise {
        let mut calls = self.oracles.iter().map(|o| {
            ext_oracle::ext(o.clone())
                .with_static_gas(GAS_PER_ORACLE)
                .with_unused_gas_weight(0)
                .get_price()
        });
        let first = calls.next().expect("at least one oracle");
        // join in list order: result i belongs to self.oracles[i]
        let all = calls.fold(first, |joint, p| joint.and(p));

        all.then(
            Self::ext(env::current_account_id())
                .with_static_gas(GAS_FOR_AGGREGATE)
                .on_prices(self.oracles.len() as u32),
        )
    }

    #[private]
    pub fn on_prices(&mut self, expected: u32) -> Option<U128> {
        let n = env::promise_results_count();
        require!(n == expected as u64, "unexpected number of results");

        let mut prices: Vec<u128> = Vec::new();
        for i in 0..n {
            match env::promise_result_checked(i, MAX_RESULT_LEN) {
                Ok(bytes) => match serde_json::from_slice::<U128>(&bytes) {
                    Ok(p) => prices.push(p.0),
                    Err(_) => log!("oracle {} returned garbage", i),
                },
                Err(_) => log!("oracle {} failed", i),
            }
        }

        if (prices.len() as u32) < self.quorum {
            log!("only {} of {} answered; price not updated", prices.len(), n);
            return None; // keep the old price, do not panic
        }
        prices.sort_unstable();
        let median = prices[prices.len() / 2];
        self.price = Some(median);
        self.updated_at = env::block_height();
        Some(U128(median))
    }
}
  • Bound N. Every member costs static gas, and the callback’s work grows with N. Seven oracles at 5 Tgas plus a 10 Tgas callback is a 45 Tgas floor before your own method’s gas. An unbounded list is an unbounded iteration bug with a gas bill attached.
  • Check the count. promise_results_count() should equal the number of members you joined. Passing the expected count as an argument makes a wiring mistake fail loudly instead of quietly misreading indexes.
  • Failures do not cancel siblings. If oracle 2 panics, oracles 0, 1, 3… still run to completion, and their gas is spent either way.
  • The slowest member sets the pace. The callback runs only after the last member’s data arrives. A member on a congested shard delays the whole join.

Partial failure when value moves#

Reads are forgiving: a missing answer just lowers the quorum. Payouts are not. If you debit a budget for ten transfers and three fail, you must credit back exactly those three, no more and no less.

Pay several recipients in parallel; refund only the transfers that failed
Rust
use near_sdk::{PromiseError, NearToken};

const MAX_PAYEES: usize = 10;
const GAS_FOR_FT_TRANSFER: Gas = Gas::from_tgas(10);
const GAS_FOR_PAYOUT_CALLBACK: Gas = Gas::from_tgas(15);

#[near]
impl Payroll {
    pub fn pay_all(&mut self, payees: Vec<(AccountId, U128)>) -> Promise {
        require!(env::predecessor_account_id() == self.owner, "owner only");
        require!(!payees.is_empty() && payees.len() <= MAX_PAYEES, "1 to 10 payees");
        let total: u128 = payees.iter().map(|(_, a)| a.0).sum();
        require!(self.budget >= total, "budget too small");
        self.budget -= total; // debit everything before any call goes out

        let mut joint: Option<Promise> = None;
        for (who, amount) in payees.iter() {
            let p = ext_ft::ext(self.token.clone())
                .with_attached_deposit(NearToken::from_yoctonear(1))
                .with_static_gas(GAS_FOR_FT_TRANSFER)
                .with_unused_gas_weight(0)
                .ft_transfer(who.clone(), *amount, None);
            joint = Some(match joint {
                None => p,
                Some(j) => j.and(p),
            });
        }
        joint.expect("non-empty").then(
            Self::ext(env::current_account_id())
                .with_static_gas(GAS_FOR_PAYOUT_CALLBACK)
                .on_paid(payees),
        )
    }

    #[private]
    pub fn on_paid(&mut self, payees: Vec<(AccountId, U128)>) -> Vec<AccountId> {
        let mut failed = Vec::new();
        for (i, (who, amount)) in payees.into_iter().enumerate() {
            // ft_transfer returns nothing. Only an explicit Failed means the
            // tokens did not move; a too-long result still means success.
            if let Err(PromiseError::Failed) = env::promise_result_checked(i as u64, 0) {
                self.budget += amount.0;
                failed.push(who);
            }
        }
        failed // the caller sees exactly who was not paid
    }
}

Shapes you can and cannot build#

ExpressionWhat runs
a.and(b).then(cb)a and b concurrently; cb after both, with 2 results
a.then(b).and(c).then(d)b after a; c alongside that chain; d after b and c (example from the near-sdk docs)
a.then(b.and(c))Panics at run time: “Cannot callback joint promise.” A joint cannot be the target of .then
a.then_concurrent([b, c])b and c concurrently after a, both receiving a’s result (near-sdk 5.16+)
a.then_concurrent([b, c]).join().then(d)The same fan-out, then d after both b and c
MoreWhy not #[callback_vec]?

#[callback_vec] prices: Vec<U128> collects every result into a vector, which looks perfect for joins. But it reads each result with promise_result_checked and panics on the first failure (“Callback computation i was not successful”). It is fine when any failed member makes the whole operation pointless and nothing needs undoing. For quorums and payouts, loop over promise_results_count() yourself, as above.

Check yourself

3 questions · progress saved in this browser

  1. 1.You join three calls with .and and chain a callback. The second call panics. What happens to the first and third calls?
  2. 2.Why does the payout callback match Err(PromiseError::Failed) instead of any Err?
  3. 3.Which expression panics at run time?