Skip to content

Cross-Context Calls

A cross-context call (xcall) lets the WASM application of one context invoke a method on another context running on the same node. It is the only way for two contexts to talk to each other inside a single node without going through the network or a human-driven RPC.

An xcall is fire-and-forget:

  • The guest queues the call from inside a normal execution by naming a target context, a function, and a parameter blob.
  • The call does not run inline. It is buffered and only dispatched after the current execution commits.
  • The caller gets no return value. The outcome of each dispatched call surfaces out-of-band as a node XCall event, not as data the caller can read.

Because there is no return channel and execution is local to the node, xcall is a one-directional signal — useful for keeping a second context in sync, not for request/response.

From application code the call is a single SDK function. It queues; it never blocks or returns a result:

use calimero_sdk::env;
// Queue a call to `index_entry` on `target_context`, dispatched after this
// execution commits. No return value comes back to the caller.
let params = serde_json::to_vec(&entry_id).unwrap();
env::xcall(&target_context, "index_entry", &params);

env::xcall takes a &[u8; 32] context id, a function name, and an opaque parameter byte slice. The host records it as an XCall { context_id, function, params } descriptor on the execution’s queue.

The target reads its caller’s provenance with env::xcall_origin:

// Returns Some(source_context_id) when this execution was dispatched via
// xcall, or None for a direct/RPC call. The node sets the origin — a guest
// cannot forge it.
match env::xcall_origin() {
Some(src) => { /* called from context `src` */ }
None => { /* direct invocation */ }
}

A method marked as an entry point declares itself with the #[app::xcall] attribute:

#[app::xcall]
pub fn index_entry(&mut self, entry_id: String) {
// reachable from other contexts in the same namespace
}

See SDK macros for how #[app::xcall] fits alongside #[app::init] and #[app::view]. It is mutually exclusive with both: an initializer is never an xcall target, and a read-only view can have no observable effect through a fire-and-forget call, so its return value would be discarded.

Lifecycle: queue during execution, dispatch after commit

Section titled “Lifecycle: queue during execution, dispatch after commit”

Queuing happens inside the host xcall function while the guest runs. Dispatch happens in the node after the source execution has committed, by iterating the queued descriptors one at a time.

  1. Queue. During execution, env::xcall calls the host, which validates sizes and limits, then pushes an XCall { context_id, function, params } onto the execution’s queue. Nothing runs yet.

  2. Commit. The source execution finishes and commits. The queued xcalls travel out on the Outcome alongside the new root hash.

  3. Dispatch. The node walks the queue. For each entry it runs the three authorization checks below, and on success calls the target method as a member of the target context, tagging the run with the source context id as the origin.

  4. Report. Each xcall emits a best-effort node event carrying its outcome. The caller is already done and never sees it.

The host rejects a queued xcall up front if it would exceed configured limits. These are the runtime defaults:

LimitDefaultError on overflow
Function name length100 bytesXCallFunctionSizeOverflow
Params size16 KiBXCallParamsSizeOverflow
Queued xcalls per execution100XCallsOverflow

A guest that trips any of these gets a host error during its own execution, before commit.

An xcall crosses a trust boundary, so it passes three independent checks. The first two are enforced by the node before dispatch; the third runs inside the target’s execution. All three fail closed.

L1 — Namespace boundary (node, fail-closed)

Section titled “L1 — Namespace boundary (node, fail-closed)”

A context may only xcall a target in its own namespace. The node resolves the namespace (the governance group) of both the source and target context and requires them to be equal. A resolution error, or either side resolving to no namespace, is a denial — not a pass.

src_namespace == target_namespace → allowed
anything else (mismatch, error, unresolved) → Denied { reason: "namespace boundary" }

A target outside the source’s namespace can never be reached, and a guest cannot widen its own reach because the namespace comes from governance state, not from the call.

After the namespace check the node also requires an owned member of the target context to execute as; if it finds none, the call is denied with no owned member.

L2 — Provenance via xcall_origin (unforgeable)

Section titled “L2 — Provenance via xcall_origin (unforgeable)”

When the node dispatches the call it executes the target as one of the target’s members and tags the run with the source context id. The target reads it with env::xcall_origin():

  • It returns Some(source_context_id) for an xcall-dispatched run.
  • It returns None for a direct/RPC call.

The origin is set by the node from the calling context and is never read from guest memory, so a guest cannot forge it. A target can therefore authorize its caller — for example accept calls only from a context it spawned — and a method can require xcall-only invocation by rejecting None.

A method is reachable by xcall only if the app author marked it #[app::xcall]. The marker is recorded in the compiled ABI (Method.xcall_callable); the node keeps the per-blob set of declared method names and, for any xcall-dispatched run, denies a method that is not in that set:

  • If the executing blob declares an xcall set and the method is not in it → denied with XCallNotPermitted.
  • If the blob declares no xcall set at all → the method is not gated (no restriction is applied).

Because the gate keys on “this run carries an origin,” it also blocks reaching internal runtime methods (e.g. sync entry points) through xcall — those are never #[app::xcall] — while leaving the origin-less sync path itself unaffected.

The caller gets nothing back. Instead, each dispatched xcall emits a best-effort XCall node event describing what happened. The event carries the target context, the function name, and one of three outcomes:

OutcomeMeaning
OkDispatched and the target execution returned Ok.
Denied { reason }Refused before running the target — wrong namespace, no owned member, or not an #[app::xcall] entry point.
ExecError { message }Dispatched, but the target method returned an error.

Event emission is best-effort: a failure to emit never aborts the dispatch loop or the source execution. The source context id rides on the wrapping event, so an observer can attribute the call.

Worked example: marketplace locks funds in escrow

Section titled “Worked example: marketplace locks funds in escrow”

Two contexts in one namespace: a marketplace that accepts a purchase, and an escrow that holds the buyer’s funds until a trade settles. When an order is placed the marketplace must tell escrow to lock the amount — fire-and-forget, node-local, exactly what xcall is for.

The marketplace dispatches the call. It serializes the parameters escrow needs and, critically, includes its own context id so escrow can match it against the origin the node will set:

use calimero_sdk::{app, env, ContextId};
#[app::logic]
impl Marketplace {
pub fn place_order(&mut self, escrow: ContextId, amount: u64) -> app::Result<()> {
#[derive(calimero_sdk::serde::Serialize)]
#[serde(crate = "calimero_sdk::serde")]
struct LockArgs { from_context: ContextId, amount: u64 }
let params = calimero_sdk::serde_json::to_vec(&LockArgs {
from_context: ContextId::from(env::context_id()), // who we are
amount,
})?;
// queued now, dispatched after this execution commits. No return value.
env::xcall(escrow.as_ref(), "lock_funds", &params);
Ok(())
}
}

Escrow’s entry point is marked #[app::xcall] — without it the node denies the call before it runs. Inside, it reads the unforgeable origin the node stamped on the run and refuses anything that does not match the self-reported sender:

use calimero_sdk::{app, env, ContextId};
#[app::logic]
impl Escrow {
#[app::xcall]
pub fn lock_funds(&mut self, from_context: ContextId, amount: u64) -> app::Result<()> {
// origin is set by the node from the calling context — never from params
let Some(origin) = env::xcall_origin().map(ContextId::from) else {
app::bail!("lock_funds is xcall-only: direct calls are rejected");
};
if origin != from_context {
app::bail!("provenance mismatch: origin {origin} != claimed {from_context}");
}
// origin is authentic — record the hold against the calling marketplace
self.locked.insert(origin, amount.into())?;
Ok(())
}
}

Walking the three checks against this call:

  • L1 passes only if marketplace and escrow resolve to the same namespace — a marketplace cannot reach an escrow it shares no governance group with.
  • L2 gives escrow env::xcall_origin() == Some(marketplace_id). The from_context in the params is just a claim; comparing it to the node-set origin is what authenticates the caller. A direct RPC call gets None and is rejected outright.
  • L3 lets the call reach lock_funds only because it carries #[app::xcall]; a method like an internal release_funds left unmarked is denied with XCallNotPermitted.

Because the outcome is a node XCall event rather than a return value, the marketplace does not learn inline whether the lock succeeded — so lock_funds is written to be idempotent and the trade-settlement design treats the lock as a signal to confirm later, not a synchronous transaction.

xcall fits node-local, fire-and-forget coordination between contexts in the same namespace:

  • Secondary indexes. A primary context records data and signals an index context to update a lookup it maintains.
  • Notifications. One context tells a sibling that something happened, without waiting for a reply.

Reach for replication/sync instead — not xcall — when you need:

  • Cross-node consistency. xcall only touches contexts on the local node; state reaching other peers is the replication/sync data plane (see Application Execution).
  • A return value or transactional coupling. xcall has neither: the caller is done before the target runs, and a target failure cannot undo the caller’s commit.
  • To cross a namespace. The L1 boundary forbids it by design.