Near Learn

Batch actions vs chained calls

Several actions on one NEAR Promise form one atomic receipt; chained .then calls are separate receipts. Build a sub-account factory that refunds on failure.

Intermediate10 min read3-question check

Every Promise targets exactly one receiver account and carries a list of actions: CreateAccount, Transfer, AddKey, DeleteKey, DeployContract, FunctionCall, Stake, DeleteAccount. Chain several of them on the same Promise::new(receiver) and you get a batch: one receipt whose actions run in order, in the same block, and either all succeed or all are reverted.

Use .then(...) instead and each step is its own receipt, executed in its own block, committing independently. Batches are the only atomic multi-step unit NEAR gives you, and they work on one receiver only.

One receipt or many#

Batch: Promise::new(x).a().b().c()Chain: p1.then(p2).then(p3)
ReceiptsOneOne per promise
ReceiversExactly one accountAny accounts
Atomic?Yes: any failing action reverts every action in the receiptNo: each receipt commits on its own
TimingAll actions in the same blockAt least one block per step
Can step 2 see step 1’s result?Only through state (e.g. the contract deploy just installed)Yes, as a promise result
Failure refundsAll deposits in the receipt go back to the predecessorOnly the failed receipt’s deposit is refunded

A factory that deploys sub-accounts#

The classic batch is a factory: create name.factory.near, fund it, deploy code, and initialise it, as one unit. If initialisation panics, you do not want a half-built account with code but no state. A batch guarantees you get all four steps or none.

Create, fund, key, deploy and initialise a sub-account in one atomic receipt
Rust
use near_sdk::serde_json::json;
use near_sdk::{
    env, log, near, require, AccountId, Gas, NearToken, PanicOnDefault, Promise, PromiseError,
    PublicKey,
};

const CHILD_WASM: &[u8] = include_bytes!("../res/child.wasm");
const MIN_DEPOSIT: NearToken = NearToken::from_near(2); // covers the child's storage
const GAS_FOR_INIT: Gas = Gas::from_tgas(20);
const GAS_FOR_CALLBACK: Gas = Gas::from_tgas(10);

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Factory {
    created: u64,
}

#[near]
impl Factory {
    #[init]
    pub fn new() -> Self {
        Self { created: 0 }
    }

    #[payable]
    pub fn create_child(&mut self, name: String, owner_key: PublicKey) -> Promise {
        let payer = env::predecessor_account_id();
        let deposit = env::attached_deposit();
        require!(deposit >= MIN_DEPOSIT, "attach at least 2 NEAR");

        // a factory can only create sub-accounts of itself
        let child: AccountId = format!("{}.{}", name, env::current_account_id())
            .parse()
            .unwrap_or_else(|_| env::panic_str("invalid sub-account name"));

        let init_args = json!({ "owner_id": payer }).to_string().into_bytes();

        // ONE receipt to the child: these five actions are atomic
        Promise::new(child.clone())
            .create_account()
            .transfer(deposit)
            .add_full_access_key(owner_key)
            .deploy_contract(CHILD_WASM.to_vec())
            .function_call("new".to_string(), init_args, NearToken::from_near(0), GAS_FOR_INIT)
            // a SEPARATE receipt back on the factory
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(GAS_FOR_CALLBACK)
                    .with_unused_gas_weight(0)
                    .on_child_created(child, payer, deposit),
            )
    }

    #[private]
    pub fn on_child_created(
        &mut self,
        child: AccountId,
        payer: AccountId,
        deposit: NearToken,
        #[callback_result] result: Result<(), PromiseError>,
    ) -> bool {
        if result.is_ok() {
            self.created += 1;
            log!("created {}", child);
            return true;
        }
        // The whole batch was reverted: no account, no code, no key.
        // The 2 NEAR came back to the FACTORY (the predecessor of the batch),
        // so pass it on to the user who paid.
        log!("creating {} failed; refunding {}", child, payer);
        Promise::new(payer).transfer(deposit).detach();
        false
    }
}
  • Order matters inside a batch. create_account must come first; function_call must come after deploy_contract. Actions execute top to bottom.
  • Sub-accounts only. create_account on Promise::new(x) only works when x is a direct sub-account of the current account. A name like alice.near can only be created by its parent: on mainnet you call the near account’s create_account method, which is a cross-contract call, not a batch of yours.
  • Know what keys you add. add_full_access_key gives the owner the power to redeploy or delete the child. Leave it out for an immutable child, or add a function-call key instead. See Access keys risk.
  • Initialise in the same batch. A deploy without the new call leaves a contract anyone could initialise first.

When a chain is the right tool#

You need a chain whenever steps touch different accounts or a later step needs an earlier step’s return value. Swap tokens, then stake the output; read a price, then lend against it. Each arrow is a commit point, so design the chain as a sequence of states you can recover from (next lessons: gas for call graphs and rollback-safe designs).

Check yourself

3 questions · progress saved in this browser

  1. 1.A batch create_account → transfer(2 NEAR) → deploy_contract → function_call("new") fails because new panics. What exists afterwards?
  2. 2.Which flow can be built as a single atomic batch?
  3. 3.A factory’s batch fails and the user’s deposit is refunded. Who receives the refund?