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#
| From | Event | To | Side effect |
|---|---|---|---|
| (none) | buy called | Pending | Debit the user’s internal balance; schedule shop.purchase(op_id) |
Pending | callback sees success | Done | None: the shop delivered |
Pending | callback sees failure | Refunded | Compensate: credit the amount back |
Pending | callback never ran (it ran out of gas) | Pending, stuck | Recovery path decides later |
Done / Refunded | anything | unchanged | Ignore and log: terminal states never move |
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
buycommits, so a secondbuyin 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
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