SDK & Apps

calimero-sdk · calimero-sdk-macros · calimero-sys · calimero-wasm-abi

Purpose

Proc-macro SDK for building Calimero applications. Developers define state with #[app::state], implement methods with #[app::logic], and emit events with #[app::event]. The SDK compiles to WASM and exposes methods as JSON-RPC-callable endpoints. All persistent collections use CRDT types for conflict-free replicated state across peers.

Proc Macro Attributes

Each attribute transforms user-written Rust into the glue code needed for WASM execution, storage integration, and ABI generation.

#[app::state] — persistent state struct

Marks the persistent state struct. Generates automatic storage keys, versioning info, Borsh derives, and CRDT merge registration. Each field with a CRDT type gets its own storage subtree.

use calimero_sdk::app; use calimero_sdk::borsh::{BorshSerialize, BorshDeserialize}; use calimero_storage::collections::{UnorderedMap, LwwRegister}; #[app::state(emits = for<'a> Event<'a>)] #[derive(Debug, BorshSerialize, BorshDeserialize)] #[borsh(crate = "calimero_sdk::borsh")] pub struct MyApp { items: UnorderedMap<String, LwwRegister<String>>, count: Counter, }
#[app::logic] — WASM-exported methods

Transforms an impl block's public methods into WASM exports. Each method gets input deserialization, output serialization, and error propagation. Private methods are not exported.

#[app::logic] impl MyApp { pub fn set(&mut self, key: String, value: String) -> app::Result<()> { self.items.insert(key, value.into())?; self.count.increment()?; Ok(()) } pub fn get(&self, key: &str) -> app::Result<Option<String>> { Ok(self.items.get(key)?.map(|v| v.get().clone())) } }
#[app::event] — typed event declarations

Declares event types that can be emitted via app::emit!. Events are Borsh-serialized and delivered to subscribers via SSE/WebSocket.

#[app::event] pub enum Event<'a> { ItemSet { key: &'a str, value: &'a str }, ItemRemoved { key: &'a str }, } // Emit without handler (fire-and-forget): app::emit!(Event::ItemSet { key: &k, value: &v }); // Emit with handler (triggers on receiving nodes): app::emit!((Event::ItemSet { key: &k, value: &v }, "on_item_set"));
#[app::init] — initialization hook

Called on first deployment. Sets up initial state and default values. Runs before any logic methods are available.

#[app::init] pub fn init() -> MyApp { MyApp { items: UnorderedMap::new(), count: Counter::new(), } }
#[app::migrate] — state migration between versions

Reads raw bytes from the previous schema, transforms into the new layout, and returns the new state. Use BorshDeserialize::deserialize (not try_from_slice) to handle trailing storage metadata.

#[derive(BorshDeserialize)] struct OldState { items: UnorderedMap<String, LwwRegister<String>>, } #[app::migrate] pub fn migrate_v1_to_v2() -> MyAppV2 { let old_bytes = read_raw().expect("no state"); let old: OldState = BorshDeserialize::deserialize(&mut &old_bytes[..]).unwrap(); MyAppV2 { items: old.items, count: Counter::new(), // new field } }
#[app::destroy] — cleanup hook

Called on application removal. Can perform resource cleanup, final event emission, or state archival before the context is torn down.

#[app::destroy] pub fn cleanup(&self) { app::emit!(Event::AppRemoved {}); app::log!("Application destroyed, state archived"); }
#[app::private] — internal helper methods

Marks a method as internal to the application. Private methods are not exported as WASM entry points and cannot be called via the admin API or JSON-RPC. They can only be called by other methods within the same impl block. Useful for shared validation, computation, or state access helpers.

#[app::logic] impl MyApp { #[app::private] fn validate_key(&self, key: &str) -> bool { !key.is_empty() && key.len() < 256 } pub fn set(&mut self, key: String, value: String) -> app::Result<()> { if !self.validate_key(&key) { app::bail!("invalid key"); } self.items.insert(key, value.into())?; Ok(()) } }
app::emit! — emit events

Emits events from logic methods. Supports fire-and-forget or handler-triggered patterns.

// Fire-and-forget (delivered to SSE/WS clients): app::emit!(Event::ItemSet { key: "foo", value: "bar" }); // With handler (executed on receiving nodes, not author): app::emit!((Event::ItemSet { key: "foo", value: "bar" }, "on_item_set")); // Handler must be commutative, idempotent, and pure: pub fn on_item_set(&mut self, key: &str, value: &str) { self.counters.increment(); // safe: CRDT op }
app::err! / bail! / log! — error handling and logging

Error and logging macros that integrate with the WASM host function ABI.

use thiserror::Error; #[derive(Debug, Error)] #[error("not found: {0}")] pub struct NotFound(String); pub fn get(&self, key: &str) -> app::Result<String> { app::log!("looking up {key}"); let Some(val) = self.items.get(key)? else { app::bail!(NotFound(key.to_owned())); }; Ok(val.get().clone()) }
State / lifecycle
Logic / export
Events / logging

App Lifecycle

From writing application code to executing methods in a live context.

1

Write App with SDK

Define your state struct with #[app::state], implement methods with #[app::logic], declare events with #[app::event]. Use CRDT collections (UnorderedMap, UnorderedSet, LwwRegister) for conflict-free replicated state.

2

Build with cargo-mero

Run cargo mero build to compile to .wasm. The build tool runs proc-macro expansion, generates the ABI manifest, and targets wasm32-unknown-unknown. The output is a single .wasm binary ready for installation.

3

Install Application

Use meroctl app install to upload the .wasm binary to a running node. The node stores it as a blob, computes its BlobId (content hash), and registers it in the application catalog.

4

Create Context

Create a context (group + application binding) via the admin API or meroctl context create. This associates the installed application with a new group, sets up storage, and subscribes to the context's gossipsub topic.

5

Execute Methods via JSON-RPC

External clients call methods on the running context via JSON-RPC 2.0. Each call is deserialized, executed in the WASM runtime, and produces a delta that is broadcast to all peers via gossip. State converges automatically.

Write App SDK macros cargo mero build .wasm output meroctl install blob store Create Context group + app JSON-RPC Execute call methods

CRDT Collections

Conflict-free replicated data types available to application state. All implement the Mergeable trait for automatic convergence across peers.

UnorderedMap<K, V>

Key-value store with per-key merge semantics. Supports nested maps — inner maps merge recursively. Values must implement Mergeable. Used for most application state.

use calimero_storage::collections::UnorderedMap;

struct State {
    users: UnorderedMap<AccountId, UserProfile>,
    nested: UnorderedMap<String, UnorderedMap<String, Value>>,
}

UnorderedSet<T>

Add-wins set. Concurrent additions and removals resolve in favor of additions. Elements must be Eq + Hash + Borsh. Ideal for membership lists, tags, and unique collections.

use calimero_storage::collections::UnorderedSet;

struct State {
    members: UnorderedSet<AccountId>,
    tags: UnorderedSet<String>,
}

LwwRegister<T>

Last-write-wins register with logical timestamp. Concurrent writes resolve by highest timestamp. Simple but effective for single-value fields where latest value is always preferred.

use calimero_storage::collections::LwwRegister;

struct State {
    title: LwwRegister<String>,
    config: LwwRegister<AppConfig>,
}

RGA (Replicated Growable Array)

Sequence CRDT for ordered collections and text. Supports concurrent insert, delete, and move operations. Preserves user intent for collaborative editing scenarios.

// Used internally for text/sequence state
// Supports: insert_at, delete_at, move
// Conflict resolution: position-based

Counter

Distributed counter generic over ALLOW_DECREMENT: bool. The default (Counter<false>, aliased GCounter) is grow-only — each node increments its own slot and value() returns the sum. Set the flag to true (alias PNCounter) to also support decrement, at the cost of a second per-node map. Naturally commutative and idempotent.

Vector

Ordered append-only list. Items are pushed to the end. Concurrent pushes from different nodes both get appended.

UnorderedMap

Key-value store with LWW (Last-Write-Wins) semantics per entry. Entries inserted, updated, and removed independently across nodes.

Storage Primitives

Beyond the public CRDT collections above, the storage layer offers wrappers that constrain who can write what. Most are signature-based: after your method returns, the context manager signs the executor's actions with their identity key, and peers verify those signatures at merge time using the runtime's ed25519_verify host function. WASM code itself does not produce signatures — it only stamps placeholders that get filled in post-execution. FrozenStorage is the exception: it has no per-identity check, immutability is structural (enforced by content-addressing). You don't need to call any signing API; pick the primitive whose guarantee matches your data and the trust model follows from the type. See crates/store for the full enforcement details.

what guarantee do you need? ├── none, public UnorderedMap / Vector / UnorderedSet / LwwRegister / Counter ├── immutable, content-addressed FrozenStorage<T> └── identity-bound writes ↓ ├── one slot per user, disjoint UserStorage<T> ├── one shared slot, named writer set SharedStorage<T> ├── shared keyspace, per-entry author AuthoredMap<K,V> └── shared sequence, per-entry author AuthoredVector<V>

See crates/store for the full comparison table including storage stamps, mutation rules, and when composition does (and does not) substitute for these primitives.

UserStorage<T>

Per-user slot keyed by PublicKey. The executor can only write into their own slot; reads are unrestricted. Use for per-member preferences, profiles, or private-but-replicated state.

use calimero_storage::collections::UserStorage;

struct State {
    profile: UserStorage<Profile>,
}

SharedStorage<T>

A single value writable by any signer in a mutable writer set. Any current writer can rotate the set unless it is frozen. Use for group-managed config or shared documents with a controlled author list.

use calimero_storage::collections::SharedStorage;

struct State {
    policy: SharedStorage<Policy>,
}

FrozenStorage<T>

Content-addressable immutable storage. insert returns a SHA-256 hash; reads are by hash. Same value always yields the same hash; entries cannot be updated. Use for attachments, snapshots, or any data that should be append-only and de-duplicated. Note: unlike the others in this section, FrozenStorage's authorization is structural — anyone can insert, immutability is enforced by content-addressing, no per-identity signature is checked at merge time.

use calimero_storage::collections::FrozenStorage;

struct State {
    attachments: FrozenStorage<Vec<u8>>,
}

AuthoredMap / AuthoredVector

Shared keyspace with per-entry ownership. Any member can insert/push, but only the original author can update, remove (Map), or tombstone (Vector) their own entries. Use for posts, comments, or any append-friendly collection where authorship matters.

use calimero_storage::collections::{AuthoredMap, AuthoredVector};

struct State {
    posts: AuthoredMap<PostId, Post>,
    feed: AuthoredVector<Event>,
}

Mergeable Trait

Universal merge interface implemented by all CRDT types. The runtime calls merge() automatically when applying incoming deltas from peers. Custom types can implement this trait for domain-specific merge logic.

pub trait Mergeable {
    fn merge(&mut self, other: Self) -> MergeResult;
    fn is_empty(&self) -> bool;
}
Key-value (Map)
Collection (Set)
Register (LWW)
Sequence (RGA)
Authorization-aware
Trait (Mergeable)

Mergeable State Lint

A compile-time check, applied by #[app::state] and #[derive(Mergeable)], that rejects field types which cannot participate in CRDT merging. Without it, those fields silently fail to converge across replicas — the worst class of distributed-systems bug, because there is no error and no log line, just data that quietly disagrees.

The problem

In Calimero, two nodes can independently update the same logical state and later sync. Convergence requires every persistent field to know how to merge with another copy of itself — that's the contract Mergeable encodes. Std collections (HashMap, BTreeMap, Vec, ...) and bare primitives (String, u64, ...) have no such contract; before this lint, they would compile fine, get persisted as opaque blobs, and silently diverge.

A particularly subtle variant: LwwRegister<HashMap<K, V>>. The wrapper makes the whole field Mergeable, so the type system accepts it — but merging happens at the register level (last-write-wins on the entire map), not at the map entry level. Two nodes inserting different keys concurrently will see one set of inserts win and the other vanish.

What the lint rejects

Walking the AST of every field in a #[app::state] struct (and every field of every #[derive(Mergeable)] struct), recursing into generic arguments:

  • Forbidden anywhere in the type tree: HashMap, BTreeMap, HashSet, BTreeSet, LinkedList, VecDeque. These have no merge semantics at any depth — including inside LwwRegister<_>.
  • Forbidden at the field root only: bare Vec, bare String, bare primitives (u8..u128, i8..i128, f32/f64, bool, char). Inside CRDT collections their use is fine — e.g. UnorderedMap<String, _> uses String as a key, which is just bytes; only the value side has to be Mergeable.
  • Allowed transparently: Option<T> and Box<T> pass through to their inner type; the lint applies as if the wrapper weren't there.

Example diagnostic

error: (calimero)> `HashMap` is not allowed here — std collections are not CRDTs and would silently diverge across replicas. Use `UnorderedMap<K, V>` from `calimero_storage::collections` instead. If you genuinely need a non-CRDT type, skip `#[app::state]` / `#[derive(Mergeable)]` and implement `Mergeable` by hand — but understand that any non-CRDT field will not converge across replicas. --> tests/compile_fail/state_hashmap_in_lww.rs:12:24 | 12 | items: LwwRegister<HashMap<String, String>>, | ^^^^^^^

#[derive(Mergeable)]

Companion proc-macro for user-defined structs that need to live inside CRDT collections (UnorderedMap<_, V>, Vector<V>, ...). Runs the same field-level lint and generates a field-by-field Mergeable impl that delegates to each field's own merge(). Re-exported as calimero_sdk::app::Mergeable.

use calimero_sdk::app::Mergeable;
use calimero_storage::collections::{Counter, LwwRegister};

#[derive(Mergeable)]
pub struct UserStats {
    visits: Counter,
    name: LwwRegister<String>,
}

Enums are rejected by the derive — there is no canonical merge rule for "left side is VariantA, right side is VariantB". Wrap in LwwRegister<MyEnum> for last-write-wins, or implement Mergeable by hand.

Escape hatch

There is no attribute opt-out. The only way to put a non-CRDT field in persistent state is to not use #[app::state] / #[derive(Mergeable)] and implement Mergeable by hand. This is intentional: any non-CRDT field will not converge, and we want that decision to require explicit code, not a flag. The hand-rolled impl on FileRecord in apps/blobs is a worked example — atomic LWW on an uploaded_at timestamp, justified inline.

Implementation: crates/sdk/macros/src/forbidden_types.rs. Negative tests: crates/sdk/tests/compile_fail/. Originally landed in PR #2276.

Sample Apps

Reference applications demonstrating SDK features, CRDT patterns, and migration workflows.

kv-store

Basic key-value store using UnorderedMap<String, LwwRegister<String>>. Demonstrates #[app::state], #[app::logic], and simple CRUD operations. The canonical "hello world" for Calimero apps.

apps/kv-store

collaborative-editor

Real-time collaborative text editor. Uses RGA for character-level CRDT operations. Demonstrates event emission for cursor position sharing and multi-user editing.

apps/collaborative-editor

access-control

Role-based access control. Uses UnorderedMap<AccountId, UnorderedSet<Role>> for per-user role sets. Demonstrates #[app::private] for internal admin checks.

apps/access-control

blobs

Binary large object storage. Demonstrates the blob API for storing and retrieving arbitrary binary data via content-addressed BlobId handles.

apps/blobs

nested-crdt-test

Nested CRDT stress test. Uses UnorderedMap<K, UnorderedMap<K, UnorderedSet<V>>> to verify recursive merge behavior under concurrent modifications.

apps/nested-crdt-test

team-metrics

Dual-implementation app: one using SDK macros, one with a custom ABI. Demonstrates both approaches side-by-side for comparison.

apps/team-metrics

private_data

Private data patterns with encrypted fields and selective disclosure. Uses LwwRegister for per-field encryption keys and #[app::private] for access gates.

apps/private_data

migrations

5-version migration suite demonstrating #[app::migrate] across schema changes. Each version adds fields, renames keys, or restructures the state tree.

apps/migrations

e2e-kv-store

End-to-end test harness for the kv-store app. Runs multi-node scenarios with concurrent writes, verifies CRDT convergence, and validates sync protocol correctness.

apps/e2e-kv-store

Developer Guide

Handler Safety Requirements

Event handlers may execute in parallel across nodes. All handlers must be:

Commutative

Order-independent. Counter increments are safe; operations that depend on prior state (create then update) are not.

Independent

No shared mutable state. Each handler should use unique keys. Two handlers writing the same map key causes a race condition.

Idempotent

Safe to retry. CRDT operations are naturally idempotent. External API calls (payments, notifications) are not.

Pure

No external side effects. Only modify CRDT state and emit logs. HTTP calls, file I/O, and non-deterministic operations are forbidden.

Common Patterns

Counter

Use Counter (G-Counter) for distributed counting. Each node tracks increments separately; value() returns the sum.

Key-Value

Use UnorderedMap<K, LwwRegister<V>> for key-value storage. Last-write-wins on concurrent updates to the same key.

Events

Use app::emit! for fire-and-forget, or app::emit!((event, "handler_name")) to trigger a handler on receiving nodes.

Available Macros

#[app::state] — Mark application state struct #[app::state(emits = Event)] — With events #[app::logic] — Mark implementation block #[app::init] — Mark constructor #[app::event] — Mark event enum #[app::migrate] — Mark migration function app::emit!(event) — Emit event app::log!("msg") — Logging app::bail!(Error::X) — Early return with error

Environment Functions

use calimero_sdk::env; let executor = env::executor_id(); // [u8; 32] let context = env::context_id(); // [u8; 32] let now = env::time_now(); // u64 nanoseconds env::log("Hello from WASM");