Rust SDK for the Macula mesh, with Kotlin and Swift bindings
Status, 2026-09-26: on the macula 12 wire. That means post-quantum ML-DSA-87 identities (in pq_hybrid, the fleet's profile, the ML-DSA-87 + RSA-PSS-4096 composite), ML-KEM hybrid key exchange, and signed requests. Calls and streams by direct dial, serving (under an org or in a node's own namespace), publish/subscribe, node-served content and the DHT are tested against in-process macula 12 stations on every
cargo test, and calls, pubsub and the DHT live against the fleet. Every link runs macula 13.2's handshake v5, bound to its TLS session, and falls back to v4 only for a station never seen on v5. Not here yet: UCAN-gated calls; see Not yet implemented. Releases before 0.4.0 speak the retired 10.x wire and cannot reach the current fleet.
A native Rust implementation of a Macula node: its identity key, a pool of links to the stations it pins by node_id, calls and streams that reach a provider at its own station, serving procedures, publish/subscribe, and the DHT. It speaks the same wire as macula (the Erlang/OTP reference) and macula-go, over QUIC (quinn) with the post-quantum TLS of macula-pqc, ML-DSA-87 from macula-mldsa, and RSA-PSS-4096 from aws-lc-rs.
macula-rust-ffi wraps it for Kotlin and
Swift. The core crate has no FFI dependency and no FFI-shaped types.
[dependencies]
macula-rust = "0.6"
tokio = { version = "1", features = ["full"] }A node needs a station to link to, pinned by its node_id, and the key of each realm it trusts, which the realm publishes. Its own key is created on first use and kept in a file its owner alone can read.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use macula_rust::cbor::Value;
use macula_rust::node_key::NodeKey;
use macula_rust::pool::{Call, Opts, Pool, Seed};
use macula_rust::profile::Profile;
use macula_rust::station_link::Publication;
let key = NodeKey::load_or_create(Path::new("node.key"), Profile::PqHybrid)?;
let mut opts = Opts::new(Arc::new(key));
opts.realm_trust = HashMap::from([(realm, realm_key)]);
let pool = Pool::connect(
vec![Seed { host: "station-fi-helsinki.macula.io".into(), port: 4433, node_id: station_id }],
opts,
)
.await?;
// A call reaches a provider by direct dial: its advertisement from the DHT,
// trusted only when the realm key authorizes it, and its station dialed.
let answer = pool
.call(Call { realm, procedure: "mcl-echo/echo".into(), payload: Value::text("hello"), ..Call::default() })
.await?;
// Publish and subscribe; topics name a kind of fact, ids go in the payload.
let mut sub = pool.subscribe(&realm, "acme/demo/greeting_sent_v1").await?;
pool.publish(Publication {
realm,
topic: "acme/demo/greeting_sent_v1".into(),
payload: Value::Map(vec![(Value::text("text"), Value::text("hi"))]),
ttl_ms: None,
})
.await?;
let event = sub.recv().await;
pool.close().await;Serving a procedure in the node's own namespace needs no org and no realm key:
use macula_rust::pool::Offer;
use macula_rust::record::own_procedure;
use macula_rust::station_link::handler;
let ring = own_procedure(&pool.node_id(), "ring"); // ~<node_id>/ring
let served = pool
.serve(Offer::unary(realm, &ring, handler(|request| async move { Ok(request.payload) })))
.await?;Runnable versions are in examples/: quickstart, serve,
publish_subscribe and content, each reading the environment described at the top of
examples/common/mod.rs.
Everything moved to the macula 12 wire, and the API with it. There is no compatibility layer.
- New identities. A macula 12 node_id derives from an ML-DSA-87 key (or
the LAMPS composite in
pq_hybrid), so no Ed25519 identity carries over.NodeKey::load_or_createmakes a new key file. Re-join your realms and re-trust your agents: anything that named your old node_id must be redone with the new one. identity::KeyPairis nownode_key::NodeKey;connection::Sessionispool::Pool(orstation_link::Linkfor one station), whose seeds carry the station's node_id and whoserealm_trustpins realm keys;direct_dial::callis simplyPool::call;resolveisPool::providers;serve_one_callisPool::servewith a handler;Trust::WebPkiis gone: every station is pinned by its node_id.- The 10.x content transfer (
content,manifest,put_direct/get_direct) is replaced by node-served content:Pool::share_content/get_content, with macula 12's SHA-384manifest.ucanandcert_chainare gone until macula 12's own arrive (see Not yet implemented). - Serving an org procedure needs the realm's org directory and the org's delegation to your node in the DHT: a realm admits orgs through a human.
| Primitive | Caller | Provider | Notes |
|---|---|---|---|
Node keys (node_key::NodeKey) |
✅ | ✅ | pq_hybrid (the fleet's) or pq_pure; key files readable by the owner only, or the platform's secure store (keystore); pq_hybrid checked against the LAMPS draft's own vector and cross-verified with macula 12.8.0 |
Pool of station links (pool::Pool) |
✅ | ✅ | Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed |
One station link (station_link::Link) |
✅ | ✅ | Handshake v5 bound to the TLS session (v4 once after unsupported_version, never after a node was seen on v5), status statements both ways, neighbour signatures on v4 links in pq_hybrid, a liveness probe |
Calls by direct dial (call, providers) |
✅ | ✅ | Candidates tried freshest first; errors arrive as LinkError::Provider / LinkError::Relay |
A node's own namespace (record::own_procedure) |
✅ | ✅ | ~<node_id>/<name>: served and called with no org and no realm key |
Streams (open_stream, Offer::stream) |
✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path |
| Publish/subscribe | ✅ | ✅ | Signed publications, delivered once across links |
Node-served content (share_content, unshare_content, get_content) |
✅ | ✅ | Shared on the node's own ~<node_id>/content_v1 and announced; a fetch checks the block, the manifest and every chunk against the content id, bounded (ContentOptions), with no realm key; manifests match macula's byte for byte (manifest) |
DHT (find_record, find_records, find_records_by_type, put_record) |
✅ | — | Records verified before they are handed on |
| Mobile bindings (Kotlin, Swift) | ✅ | ✅ | macula-rust-ffi, below |
The link and the pool are ported from macula-go v0.12.0's stationlink and
pool, and every wire format is checked against macula-go's and macula's own
vectors (tests/vectors/). unsafe_code = "forbid" holds across the
workspace; the unsafe code is inside dependencies (quinn, aws-lc-rs).
A payload is what macula's wire CBOR carries: Value::Null, Int, Float,
Text, Bytes, List and Map. There is no boolean: write 1 or 0. A
decoded payload obeys macula 12's decoding rule (depth 64, 131,072 elements,
integers within ±2^63, text or integer map keys, no duplicates).
macula-rust-ffi wraps the pool with UniFFI
proc macros: FfiNodeKey, FfiPool, FfiSubscription, FfiStream, and two
handlers the app implements, FfiCallHandler and FfiStreamHandler
(suspend fun in Kotlin, async throws in Swift). FfiPool also shares and
fetches content (shareContent, getContent, FfiContentOptions). Every id
crosses as bytes and is checked for its length (32 bytes, or 50 for a content
id).
cargo build -p macula-rust-ffi --release
cargo run -p macula-rust-ffi --release --bin uniffi-bindgen -- generate \
--library target/release/libmacula_rust_ffi.so \
--language kotlin --out-dir bindings-kotlinclass Echo : FfiCallHandler {
override suspend fun handle(request: FfiRequest): FfiValue = request.payload
}
val key = try {
FfiNodeKey.loadFromKeystore("io.macula.myapp", "node-identity", FfiProfile.PQ_HYBRID)
} catch (e: FfiException.KeystoreNotFound) {
FfiNodeKey.generate(FfiProfile.PQ_HYBRID).also { it.saveToKeystore("io.macula.myapp", "node-identity") }
}
val pool = FfiPool.connect(key, listOf(FfiSeed(host, 4433.toUShort(), stationId)),
FfiPoolOptions(realmTrust = listOf(FfiRealmKey(realm, realmKey))))
pool.serve(realm, ownProcedure(pool.nodeId(), "ring"), Echo())
val answer = pool.call(realm, "mcl-echo/echo", FfiValue.Text("hello"), null, 5_000uL)On Android the platform keystore needs one call at app start,
Keyring.initializeNdkContext(applicationContext); see the keystore
module's documentation. iOS needs nothing extra.
CI generates both bindings on every push to master and every pull request; the apps that use them compile them. The FFI crate needs Rust 1.91, the core crate 1.89.
- UCAN-gated calls and serving. macula 12 uses post-quantum UCANs; calls carry no token yet, and a gated procedure cannot be served.
- Station discovery beyond the seeds. macula's discovery call is not served by the fleet today (macula-io/macula#31); give the pool its seeds.
./scripts/build-teststation.sh # macula-go's in-process stations, to target/teststation
cargo test --workspaceThe integration tests (tests/station_link.rs, tests/pool.rs,
macula-rust-ffi/tests/pool_ffi.rs) run against tests/teststation, a Go
helper around macula-go's teststation. It starts in-process macula 13.2
stations, which answer handshake v5, realms and orgs as each test asks, and reports what a station sees
(who is connected, what is advertised or subscribed, how many streams it
relays). A test fails, not skips, when the helper is missing. No network is
needed. Go ≥ 1.27 builds the helper.
tests/live.rs runs against one real station and is ignored unless asked:
MACULA_RUST_LIVE_SEED=station-fi-helsinki.macula.io:4433 \
MACULA_RUST_LIVE_STATION_ID=<64 hex> MACULA_RUST_LIVE_REALM=<64 hex> \
MACULA_RUST_LIVE_REALM_KEY=<hex> cargo test --test live -- --ignoredWith a key generated for the run and never saved, it reads the DHT, calls
mcl-echo/echo by direct dial and hears its own publication.
scripts/cross-verify-macula.sh renews the pq_hybrid signatures that crossed
both ways with macula (tests/vectors/identity/macula_12_cross).
| Repo | Approach |
|---|---|
| macula | The reference SDK (Erlang/OTP) |
| macula-go | Go port; this crate's link and pool follow it |
| macula-ts | FFI binding over macula-go, for Node.js |
| macula-php | FFI binding over macula-go, for PHP |
| macula-station | The station: DHT, SWIM, routing, peering |
| macula-realm | Managed-realm identity + certificate authority |
Licensed under the Apache License, Version 2.0 (LICENSE or http://www.apache.org/licenses/LICENSE-2.0).
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you shall be licensed as above, without any additional terms or conditions.
Built with the BEAM's protocol, ported to Rust — sponsor the work if this saved you some time