Embed via C FFI
rings-node exports a small C ABI so a host that cannot call Rust directly — Python, Swift,
Kotlin, or any language with a C foreign-function interface — runs a full node in its own
process. The ABI is declared in
crates/node/include/rings.h,
generated by cbindgen from the ffi feature. It is deliberately narrow: one constructor, one
request entry point that carries the whole JSON-RPC API, and the ownership calls those two
need.
Build the library
cargo build -p rings-node --features ffi
The library lands in target/debug/ as librings_node.dylib (macOS), librings_node.so
(Linux), or rings_node.dll (Windows); add --release for target/release/.
The ABI
| Function | Role |
|---|---|
rings_node_init_logging(level) | Installs the logger once; level is Debug, Info, Warn, Error, or Trace. |
rings_node_new_provider_with_callback(network_id, ice_server, stabilize_interval, account, account_type, storage_path, signer) | Creates a provider keeping its stores under storage_path, and returns its handle, or NULL when creation fails. |
rings_node_listen(provider) | Starts message listening and Chord stabilization. |
rings_node_request(provider, method, params) | Calls one method of the JSON-RPC API with a JSON params string; returns the reply as a string, or NULL when validation or execution fails. |
rings_node_string_free(value) | Releases a string returned by rings_node_request. |
rings_node_provider_destroy(provider) | Stops the listener and every task of the node, closes its connections, flushes its measurements, and releases every store under its storage_path, so a provider created again on that path in this process opens them; then destroys the provider, and the handle is invalid afterwards. |
The constructor takes the same values config.yaml gives a native node: the network_id, an
ICE server list in [turn|stun]://<host>:<port>;... form, the stabilization interval in
seconds, the account as a hex string, and its account_type (eip191, ed25519, or
bip137). The signer is a callback void signer(const char *proof, char *out) that writes
exactly 65 signature bytes over proof into out; the node calls it to sign its session and
never sees the private key.
storage_path is a directory the provider owns. Under it the provider opens the same stores a
native node opens beside its data store, each at its authority: data (the DHT data store),
transaction-replay (the replay windows, authoritative), relay-inbox (held mail, retained),
dht-removals (the data topics’ uncollected removals, retained), onion-entry-guards (a durable
cache), and measure with provisional-evidence. Pass the same
path on every run of the same node: the replay windows must survive a restart, or the node
would accept replayed transactions and re-sign used sequences (#1022). A fresh path is a fresh
node state.
The provider owns its stores exclusively while it lives. Each store takes an exclusive lock on
its own directory at open (a .lock file, flock on unix and LockFileEx on Windows) and
holds it until the provider is destroyed (#1039). A second provider, or a native daemon, given a
store this provider holds fails to open it, so rings_node_new_provider_with_callback returns
NULL instead of interleaving its writes with this one’s. The lock is per store, so providers
with distinct roots never conflict. A file system that offers no such lock fails the open.
Lifecycle
rings_node_init_logging(Info);
struct ProviderHandle *provider = rings_node_new_provider_with_callback(
1, "stun://stun.l.google.com:19302", 15, account, "eip191", "/var/lib/myapp/rings", signer);
rings_node_listen(provider);
char *reply = rings_node_request(provider, "connectPeerViaHttp",
"{\"url\": \"https://node.rings.rs\"}");
/* ... read the JSON reply ... */
rings_node_string_free(reply);
rings_node_provider_destroy(provider);
The ownership rules are the ones the header states, and every wrapper in the repository follows them:
- A handle is released exactly once with
rings_node_provider_destroy, never used afterwards, and never destroyed concurrently with another call on it. - Every non-
NULLstring fromrings_node_requestis released exactly once withrings_node_string_free; passingNULLis a no-op. - The signer callback stays alive for as long as the provider does: the host retains it.
Wrappers
examples/ffi holds three
wrappers over this ABI with the same desktop flow — node identity, offer/answer exchange, peer
connection polling, end-to-end handshake and messaging, and event polling:
-
rings.py: acffibinding that parsesrings.hand loads the library for the host platform. Its tests run against a built library:python -m pip install web3 cffi pytest RINGS_FFI_REQUIRE_LIBRARY=1 pytest examples/ffi/tests -
swift/: aRingsRuntime(libraryPath:)wrapper that loads the library dynamically, with a smoke test that also verifies every required symbol when given the real library:bash examples/ffi/swift/test.sh target/debug/librings_node.dylib -
kotlin/: the same flow over JNA.
They stop at the portable C ABI; mobile packaging (iOS NetworkExtension, Android VpnService) is outside it.