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.

The optional version = N attribute sets the schema target version for identity-gated migration (see migrate_my_entries in the migrations guide §5). When the state contains an AuthoredMap / AuthoredVector field and version = N is declared, the macro auto-generates a migrate_my_entries() WASM export that sweeps the caller's stale entries up to that target. Defaults to 0 (inert).

use calimero_sdk::app; use calimero_sdk::borsh::{BorshSerialize, BorshDeserialize}; use calimero_storage::collections::{UnorderedMap, LwwRegister}; // Basic usage — no events, no version target #[app::state] pub struct MyApp { items: UnorderedMap<String, LwwRegister<String>> } // With events #[app::state(emits = for<'a> Event<'a>)] pub struct MyApp { items: UnorderedMap<String, LwwRegister<String>> } // With schema-version target (auto-generates migrate_my_entries for AuthoredMap/AuthoredVector fields) #[app::state(version = 2, emits = for<'a> Event<'a>)] pub struct NotesV2 { notes: AuthoredMap<String, LwwRegister<String>>, // migrate_my_entries auto-generated }
#[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::view] — declare a read-only method

Marks a public method as read-only. The declaration is stored in the compiled ABI so the node runtime can take a shared read lock instead of an exclusive write lock, enabling parallel execution of concurrent view calls. Mutually exclusive with #[app::init] (initializers always write state).

#[app::logic] impl MyApp { // Exclusive write lock — state may change pub fn set(&mut self, key: String, val: String) -> app::Result<()> { .. } // Shared read lock — concurrent calls run in parallel #[app::view] pub fn get(&self, key: &str) -> app::Result<Option<String>> { .. } }

A method marked #[app::view] must take &self (not &mut self); the macro enforces this at compile time. No state writes may occur inside a view method — the intent is compiler-documented and runtime-exploited for parallelism. As of 0.11.0-rc.6, #[app::view] is registered as a no-op marker and resolves correctly when used through a dependency crate. Design note: see docs/design/context-method-read-write-intent.md for the parallel-read accelerator design.

#[app::migration_check] — pre-commit migration guard

Optional companion to #[app::migrate]. Runs against a staging buffer — before the migrated state is committed — and returns bool. false triggers a logical abort: the staging buffer is discarded and the context stays on v1 with zero residue (no snapshot/restore needed). A failed check is retryable; no migration marker is recorded, so the context re-runs migrate+check on its next access.

#[derive(BorshSerialize, BorshDeserialize)] struct MigrationWitness { v1_count: u64 } // Migrate returns (State, Witness) so the check has a v1 baseline #[app::migrate] fn migrate_v1_to_v2() -> (DocV2, MigrationWitness) { let old = read_v1(); let v1_count = old.items.len().unwrap_or(0) as u64; (DocV2 { items: old.items, .. }, MigrationWitness { v1_count }) } // Check: old scalars + new collections + optional witness → bool #[app::migration_check] fn check_migration(_old: DocV1, new: DocV2, w: MigrationWitness) -> bool { matches!(new.items.len(), Ok(n) if n as u64 == w.v1_count) }

Both the check and any witness must be deterministic pure functions of the v1 state — they run independently on every node and must reach the same verdict. See the migrations guide §7 for the full contract.

Determinism — no node-local inputs. A migration check (and its witness) must never read env::time_now(), RNG, env::executor_id(), or any other node-local state. Two nodes evaluating different wall-clocks would reach opposite verdicts and fork the network — exactly the divergence Counter::increment / RGA::insert already panic on during a migrate (see store.html, nonce/clock section). Treat the inputs as the only legal source of truth: derive the verdict purely from old, new, and the borsh witness carried out of #[app::migrate]. Use the witness to smuggle any v1-derived quantity (counts, hashes) the check needs, rather than recomputing it from ambient state.

#[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. A drop-stamping guard on value_mut() prevents an incorrect last-writer timestamp from being recorded on mutable-borrow drop (fixed in 0.11.0-rc.6).

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> (alias: PermissionedStorage<T, WriterSetAcl>) ├── one shared slot, single transferable owner Ownable<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>,
}

Ownable<T>

A single-owner variant of SharedStorage — the writer set is always size 1. Ownership is transferable (transfer_ownership(new_owner)) and renounce-able (renounce_ownership(), which freezes the value). The only_owner() call is a fail-fast API guard; the actual security boundary is merge-time signature verification. See calimero-components for the full component model.

use calimero_storage::collections::Ownable;

struct State {
    config: Ownable<LwwRegister<String>>,
}

Op-granular writer capabilities (OpMask)

The SharedStorage writer set now supports per-writer OpMask bits (INSERT, UPDATE, DELETE, ADMIN). Use grant_capability(who, mask) to restrict a writer to append-only (OpMask::APPEND) or deny deletes. The mask is enforced at merge time by the ProtocolAuthorizer after signature verification — a writer with DELETE not set cannot delete even by crafting a raw delta. A writer with no explicit mask resolves to FULL (all operations permitted).

use calimero_storage::entities::OpMask;

// Grant write-but-not-delete to a collaborator:
self.shared.grant_capability(collaborator, OpMask::APPEND)?;

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

custom-key-store

Demonstrates using a custom key type in a Calimero collection. Shows how to implement AsRef<[u8]> (the SDK's StorageKey requirement) for arbitrary structs or numeric types, which are otherwise rejected at compile time as collection keys.

apps/custom-key-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 event type #[app::state(version = N)] — Set schema target (auto-generates migrate_my_entries) #[derive(app::Migrate)] — Auto-derive migration body (with #[migrate(…)] annotations) #[app::logic] — Mark implementation block #[app::init] — Mark constructor #[app::view] — Declare method read-only (shared lock, parallel execution) #[app::event] — Mark event enum #[app::migrate] — Mark migration function #[app::migration_check] — Pre-commit guard; false → logical abort, zero residue 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");