Near Learn

Rollback-safe designs: pending states and sagas

Design NEAR cross-contract flows that survive failure: per-operation ids, pending states, idempotent callbacks, compensating actions and a recovery path.

Advanced17 min read3-question check

The security lessons give you the core rule: debit before the call, restore in the callback. That works for one call with one callback that always runs. Real flows have more moving parts: several steps, callbacks that can themselves fail, users who retry, admins who need to fix things by hand. This lesson turns the rule into a structure you can reason about: every async operation is a record with an id and an explicit state machine.

Distributed-systems people call this a saga: a sequence of local transactions, each with a compensating action that undoes it if a later step fails. On NEAR each receipt is one local transaction, so the vocabulary fits exactly.

Make the operation a first-class record#

FromEventToSide effect
(none)buy calledPendingDebit the user’s internal balance; schedule shop.purchase(op_id)
Pendingcallback sees successDoneNone: the shop delivered
Pendingcallback sees failureRefundedCompensate: credit the amount back
Pendingcallback never ran (it ran out of gas)Pending, stuckRecovery path decides later
Done / RefundedanythingunchangedIgnore and log: terminal states never move
States and the only transitions allowed
Operation ids, a pending state and an idempotent callback
Rust
use near_sdk::json_types::U128;
use near_sdk::store::LookupMap;
use near_sdk::{
    env, ext_contract, log, near, require, AccountId, Gas, NearToken, PanicOnDefault, Promise,
    PromiseError,
};

const GAS_FOR_PURCHASE: Gas = Gas::from_tgas(20);
const GAS_FOR_ON_PURCHASE: Gas = Gas::from_tgas(10);
const GAS_FOR_BUY: Gas = Gas::from_tgas(10);

#[ext_contract(ext_shop)]
pub trait Shop {
    // the shop records order_id, so it can answer "did order X go through?"
    fn purchase(&mut self, order_id: u64, item: String) -> String;
}

#[near(serializers = [borsh, json])]
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum OpStatus {
    Pending,
    Done,
    Refunded,
}

#[near(serializers = [borsh, json])]
#[derive(Clone)]
pub struct Op {
    pub user: AccountId,
    pub amount: U128,
    pub status: OpStatus,
    pub created_at: u64, // block height
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Buyer {
    shop: AccountId,
    balances: LookupMap<AccountId, u128>, // yoctoNEAR the user deposited earlier
    ops: LookupMap<u64, Op>,
    next_op: u64,
}

#[near]
impl Buyer {
    pub fn buy(&mut self, item: String, price: U128) -> Promise {
        require!(
            env::prepaid_gas()
                >= GAS_FOR_BUY.saturating_add(GAS_FOR_PURCHASE).saturating_add(GAS_FOR_ON_PURCHASE),
            "attach more gas"
        );
        let user = env::predecessor_account_id();
        let balance = self.balances.get(&user).copied().unwrap_or(0);
        require!(balance >= price.0, "insufficient balance");

        // local transaction 1: debit + record, committed when this method ends
        self.balances.insert(user.clone(), balance - price.0);
        let op_id = self.next_op;
        self.next_op += 1;
        self.ops.insert(op_id, Op {
            user,
            amount: price,
            status: OpStatus::Pending,
            created_at: env::block_height(),
        });

        // local transaction 2 happens on the shop; the op id is the idempotency key
        ext_shop::ext(self.shop.clone())
            .with_attached_deposit(NearToken::from_yoctonear(price.0))
            .with_static_gas(GAS_FOR_PURCHASE)
            .purchase(op_id, item)
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(GAS_FOR_ON_PURCHASE)
                    .with_unused_gas_weight(0)
                    .on_purchase(op_id),
            )
    }

    #[private]
    pub fn on_purchase(
        &mut self,
        op_id: u64,
        #[callback_result] result: Result<String, PromiseError>,
    ) -> OpStatus {
        let Some(mut op) = self.ops.get(&op_id).cloned() else {
            log!("unknown op {}", op_id);
            return OpStatus::Refunded;
        };
        // idempotent: only Pending may move, whoever calls this and however often
        if op.status != OpStatus::Pending {
            log!("op {} already {:?}", op_id, op.status);
            return op.status;
        }
        op.status = match result {
            Ok(_) => OpStatus::Done,
            Err(_) => {
                // compensating action: re-read and add, never overwrite
                let current = self.balances.get(&op.user).copied().unwrap_or(0);
                self.balances.insert(op.user.clone(), current + op.amount.0);
                OpStatus::Refunded
            }
        };
        let status = op.status;
        self.ops.insert(op_id, op);
        status
    }

    pub fn get_op(&self, op_id: u64) -> Option<Op> {
        self.ops.get(&op_id).cloned()
    }
}

Why each piece is there#

  • A per-operation id. The callback gets op_id, not the amount or the user. Everything it needs is in the record, so the record is the single source of truth, and support staff, indexers and front ends can look an operation up.
  • The id travels to the other contract. The shop stores order_id, so later you can ask the shop whether order 42 happened. Without a shared key, an operation whose callback was lost is unknowable.
  • Optimistic debit. The balance is gone the moment buy commits, so a second buy in the same block cannot spend it again.
  • Idempotent transitions. The status check makes running the callback logic twice harmless. The runtime never calls a callback twice, but your recovery path (below) might race with a late callback.
  • Re-read, then add. The compensation adds to the current balance, because deposits may have arrived while the op was pending.
  • A terminal answer. The callback returns the final status, so the transaction result tells the user exactly what happened.

The recovery path for stuck operations#

If on_purchase runs out of gas or hits a bug, the op stays Pending forever and the user’s money is in limbo. Plan for it. A recovery method should be permissionless or owner-only, time-gated, and resolve from facts, not from guesses: ask the other contract what happened to this id, then apply the same transition the callback would have.

SpoilerShow a reconcile method
Assumes the shop exposes `order_exists(order_id) -> bool`
Rust
const STUCK_AFTER_BLOCKS: u64 = 1_000;

// add to the ext_shop trait:
//     fn order_exists(&self, order_id: u64) -> bool;

#[near]
impl Buyer {
    pub fn reconcile(&mut self, op_id: u64) -> Promise {
        let op = self.ops.get(&op_id).cloned().expect("unknown op");
        require!(op.status == OpStatus::Pending, "op is not pending");
        require!(env::block_height() > op.created_at + STUCK_AFTER_BLOCKS, "too early");

        ext_shop::ext(self.shop.clone())
            .with_static_gas(Gas::from_tgas(5))
            .order_exists(op_id)
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(GAS_FOR_ON_PURCHASE)
                    .on_reconcile(op_id),
            )
    }

    #[private]
    pub fn on_reconcile(
        &mut self,
        op_id: u64,
        #[callback_result] exists: Result<bool, PromiseError>,
    ) -> OpStatus {
        let Some(mut op) = self.ops.get(&op_id).cloned() else { return OpStatus::Refunded };
        if op.status != OpStatus::Pending {
            return op.status; // a late callback already settled it
        }
        match exists {
            Ok(true) => op.status = OpStatus::Done,
            Ok(false) => {
                let current = self.balances.get(&op.user).copied().unwrap_or(0);
                self.balances.insert(op.user.clone(), current + op.amount.0);
                op.status = OpStatus::Refunded;
            }
            Err(_) => return OpStatus::Pending, // could not find out: try again later
        }
        let status = op.status;
        self.ops.insert(op_id, op);
        status
    }
}

Note the third branch: when the query itself fails, the op stays Pending. Guessing “refund” there could pay out for an order that went through.

Check yourself

3 questions · progress saved in this browser

  1. 1.Why does the callback check op.status != OpStatus::Pending before doing anything?
  2. 2.The shop call failed. Your callback runs. Has the attached NEAR already been refunded to your contract?
  3. 3.In on_reconcile, the query to the shop fails. What should happen to the pending operation?