React Native
This section covers installing and integrating the DeRec protocol on iOS and Android with the @derec-alliance/react-native package.
React Native SDK Overview
The DeRec React Native SDK provides a managed surface over the same DeRecProtocol orchestrator the Rust SDK exposes. Unlike the Node.js and Web packages, it is not a WASM build: it ships a compiled C++/JSI native module wrapping the Rust derec-library core over its C ABI — an iOS XCFramework and per-ABI Android static libraries. All cryptography, protobuf handling, and protocol validation run in native code; the JavaScript side only marshals arguments and results.
The API surface deliberately mirrors @derec-alliance/nodejs method-for-method: the same DeRecProtocol / DeRecProtocolBuilder classes, the same DeRecEvent union, the same five store interfaces and Transport. Code written against the Node package usually ports with only an import change — the handful of places where it does not are listed under Differences from the Node package.
The SDK allows applications to:
Distribute Secret shares across trusted Helpers using a threshold secret-sharing scheme
Verify Helpers still hold shares
Recover the Secret from a threshold of Helpers
Optionally extend the Owner identity across multiple devices via the replica feature, with automatic Secret synchronization between them
Installation
npm install @derec-alliance/react-native@0.0.8
# or: yarn add @derec-alliance/react-native@0.0.8These chapters document lib-derec 0.0.8.
iOS additionally requires a pod install:
Autolinking picks up the DeRec.podspec and the Android Gradle module automatically via react-native.config.js — no manual native project edits are needed on either platform. There is no init() call and no WASM asset for a bundler to resolve: the native module installs the host object itself (on iOS before any JavaScript runs, on Android on first use).
The package is pre-1.0. Breaking changes between releases are expected — pin an exact version ("@derec-alliance/react-native": "0.0.8") in your package.json and treat upgrades as a deliberate review step. An upgrade also means a native rebuild, not just a Metro reload.
Upgrade iOS apps built on 0.0.5 or earlier. The iOS static libraries in those releases were stripped with a tool that merged distinct function bodies under one symbol name, so the app linked and ran but some messages were encoded wrongly — most visibly a truncated protobuf varint, which peers refused with protobuf_decode. The failure varied with random values and looked intermittent. Messages those builds sent may have been rejected by their peers. Android was never affected.
Package size
This package is large, and it is worth knowing that before npm install runs.
Download (gzipped tarball)
~40 MB
Unpacked in node_modules
~125 MB
The bulk is the statically linked Rust core, shipped for all six supported architectures: ios/DeRecFFI.xcframework (~55 MB) and android/src/main/jniLibs (~68 MB). There is no per-architecture package to install instead.
Requirements
react-native
>= 0.76.0, New Architecture
react
>= 18.2.0
iOS deployment target
13.4+
Android minSdk
24
The New Architecture is required, not merely supported. The native module's only job is to install the __DeRec host object into the JavaScript runtime, and on iOS the sole path that does so is RCTTurboModuleWithJSIBindings — a hook RCTTurboModuleManager calls only for modules that take the TurboModule path. The module exports no methods at all, so the JavaScript-side NativeModules.DeRec.install() fallback has nothing to call on iOS; a module registered the legacy way silently leaves globalThis.__DeRec undefined. The pre-0.76 workaround — casting the bridge to RCTCxxBridge and reading its runtime — is not an option either, because a bridgeless app has no RCTCxxBridge, and bridgeless is the default from 0.76 onward.
Android reaches the runtime through a blocking synchronous install() instead, and reads the call invoker off the React context rather than the catalyst instance for the same bridgeless reason.
The practical failure mode is a thrown "DeRec native module is not installed" on first use, in an app that built and linked cleanly. If you hit that with the New Architecture disabled, enabling it is the fix.
Supported platforms
The compiled Rust core ships as a vendored DeRecFFI.xcframework on iOS and as per-ABI static libraries on Android.
iOS
device arm64; simulator arm64 + x86_64
Android
arm64-v8a, armeabi-v7a, x86_64
Expo Go cannot load this package. Expo Go is a pre-built binary containing only the native modules Expo shipped with it; any package with custom native code — this one included — needs an Expo dev build (npx expo run:ios / npx expo run:android, or eas build --profile development). This is not specific to DeRec; it is true of every React Native library that ships native code.
Plain (non-Expo) React Native projects are unaffected — a normal debug or release build already includes whatever native modules are linked in. If getNative() throws "DeRec native module is not installed", the cause is almost always a JavaScript-only reload after adding the dependency: rebuild the app (pod install on iOS, Gradle sync on Android).
Quick Start
Implement the five store interfaces, a transport, and drive the orchestrator. The example below is the minimum scaffold; the flow chapters show how each protocol round-trip flows through process(...).
The builder requires withChannelStore, withShareStore, withSecretStore, withUserSecretStore, withStateStore, withTransport, and withOwnTransports. A missing store fails fast at build() with a JavaScript error naming it (StoreBindings: missing store "channelStore"). A missing or empty own-transport list fails in the Rust core with a DeRecError of code invalid_input (category input); two entries of the same protocol fail with transport_invalid. Optional setters (withThreshold, withTimeouts, withCommunicationInfo, withAutoRespondOnFailure, withUnpairAck, withAutoReplyTo, withAutoAccept, withUnsafeConnection, withReplicaId, withParameterRange) tune flow behaviour, transport policy, and replica participation. Values you never set are omitted from the config entirely, so the library's own default applies rather than one frozen into the binding.
Own transports
withOwnTransports(Endpoint[]) lists every endpoint this application serves, in preference order, at most one per protocol. Endpoint is { uri, protocol }, where protocol is "https" or "grpc" and the URI scheme is one of https://, http://, grpcs://, grpc://. Every listed endpoint must actually be served: delivery is push-only, so an endpoint that is advertised but not served makes pairing succeed and replies vanish. Change the list at runtime with await protocol.setOwnTransports([...]), which validates every entry before replacing the whole list; see Channel management.
withUnsafeConnection(true) accepts plaintext http:// and grpc:// endpoints, to any host, including endpoints a peer advertises. Default false; a loopback endpoint of your own works without it. On a physical device talking to a laptop over the LAN neither side is loopback, so both sides need the flag — a common first stumble in mobile development. See Transport endpoints and plaintext connections.
withParameterRange(Partial<ParameterRange>) declares the bounds this node advertises at pairing; a peer whose range does not intersect is refused with incompatible_parameter_range. Every bound is optional; an unset bound is no constraint.
Transport
Transport.send(endpoints, message) receives every endpoint the peer advertised, in the peer's own order, never empty. The library does not rank them. Deliver once — stop at the first endpoint that accepts the message — and reject only when none did. Rather than writing send by hand, write a SendOne ((endpoint, message) => Promise<void>) and wrap it:
sequentialFailover(dialer)— tries each endpoint in order, stops at the first success. The right default.singleEndpointTransport(dialer)— uses the first endpoint only. Use it when failover was considered and declined; indexingendpoints[0]inline hides that decision.
free() and instance lifetime
DeRecProtocol owns a native handle and a worker thread. free() releases both; it is safe to call more than once, and it drains any work still in flight before releasing, so freeing while a call is pending completes rather than crashing. A call issued after free() fails with "DeRec: protocol instance has been freed" rather than queueing onto a worker that will never run it. [Symbol.dispose]() calls free(), for runtimes that support using.
Not calling free() leaks the handle and its thread for the life of the process. Tie it to whatever owns the instance — a React effect cleanup, a service-object teardown, a navigation unmount.
Timeouts
withTimeouts configures four independent budgets. Every field is optional — omit one to keep the library default for it; omit the call entirely and no timeouts object is sent at all.
inbound_message_secs is a security boundary — it bounds how stale a message may be and still be accepted, so it has to tolerate transport latency and clock skew. A message older than it is dropped and reported as { type: "MessageIgnored", reason: "Expired" }. On mobile this matters more than on a server: a device that was asleep, backgrounded, or on a flaky connection routinely delivers messages later than a datacentre peer would. The other three are liveness budgets. See Concurrency and scheduling.
tick()
Timeouts advance only inside process and tick. A publish whose peers all go quiet has nothing left to close it: the round stays open and no SharingComplete is ever emitted.
tick() is idempotent and safe on an idle protocol — with nothing in flight it touches no state and returns an empty array. It mutates the same round state an inbound response does, but the binding serialises everything onto one worker, so no extra locking is needed here. Note that a JavaScript timer does not fire while the app is backgrounded on either platform: on resume, run one tick() before assuming a round is still open. See Channel management and Timeouts and tick().
Utility functions
generate_replica_id()— a fresh random replica id (bigint, never0). Generate it once per device, persist it, and pass the same value towithReplicaIdon every start. See Pairing.protocol_version()— the DeRec protocol version this build writes into every envelope,{ major, minor }. Not the package version. See Versioning.channelFilterMatches(filter, id, status, role)— evaluates aHelperFilter/ReplicaFilterfor a store that lists and filters in memory. See Implementing a persistent store.advertisedEndpoints(message)— thesupported_transportsa decoded peer message advertises, in the peer's order, unvalidated.
Threading model
The native module runs each DeRecProtocol instance's flow logic (start, process, tick, accept, reject, restore, …) on a dedicated worker thread, off the JavaScript thread — precisely because that logic calls back into your store and transport implementations, which are themselves JavaScript and can take an unbounded amount of time (disk I/O, network requests). Those methods return a Promise. The worker is serial, so calls on one instance queue rather than interleave; only distinct instances on the same secretId and stores need an application lock.
Do not call protocol methods from inside a store callback. A store method (load, save, remove, …) is invoked as a callback into JavaScript with the protocol's worker blocked waiting on the promise that callback returns. Calling a DeRecProtocol method — free() included — from inside the body of that callback would enqueue work onto the very worker already blocked waiting for you — an unbreakable deadlock. The binding detects this and rejects the call with "DeRec: protocol methods cannot be called from inside a store callback" instead of hanging.
The restriction is exactly that narrow: it applies to the store method's own body, not to the whole window in which your returned promise is pending, and not to other JavaScript that merely runs at the same time. Protocol calls from anywhere else are always safe.
Primitives are the exception — they run on the JavaScript thread. The functions under primitives.* and envelope.* are pure byte transforms with no store or transport callbacks, so they execute synchronously, in-line. That includes primitives.sharing.request.split (protect_secret), which does real cryptographic work: Shamir splitting, AES-GCM encryption, and Merkle tree construction over the shares. For a handful of small shares this is fast; for larger secrets or higher share counts it blocks rendering for its duration. Applications that need this work off the JS thread should drive it through DeRecProtocol (start(FlowKind.ProtectSecret, ...)) rather than calling the primitive directly.
Protocol Flows
The SDK exposes every DeRec flow through a single protocol.start(flowKind, params) entry point plus the inbound process / accept / reject loop. FlowKind has nine members: Pairing, Discovery, ProtectSecret, VerifyShares, RecoverSecret, Unpair, UpdateChannelInfo, ReplicaDiscovery, UnpairReplica. The chapters below cover them; the protocol-level catalog lives in Protocol Specification / Flows.
Two FlowKinds are replica-only and require withReplicaId — and on mobile they are the common case, since a second phone or tablet holding the same Secret is exactly what the replica feature is for:
start(FlowKind.UnpairReplica, ...) announces the removal — it removes nothing on its own and emits no ReplicaRemoved. The removal completes only when the application publishes a roster omitting that member, via an ordinary start(FlowKind.ProtectSecret, ...). An app that calls the flow and waits for the event without publishing waits forever, and a group with no secret to publish cannot complete a removal at all. See Channel management and UnpairReplica.
Establish secure communication between two participants — Owner ↔ Helper, or Replica pairings
Split the Secret and distribute shares to Helpers; sync the full Secret to every other replica group member
Verify Helpers still hold shares
Discover versions, recover shares, reconstruct the Secret, and restore
Unpair, UpdateChannelInfo, ReplicaDiscovery, UnpairReplica, rejecting requests, auto-accept, timeouts and housekeeping
Each flow follows the same shape: the initiator calls protocol.start(FlowKind.X, params); the library produces outbound envelopes through your Transport.send; the responder feeds inbound envelopes to protocol.process and answers any { type: "ActionRequired" } event with protocol.accept(action) or protocol.reject(action, status, memo).
Message Model
The SDK operates on serialized protocol envelopes rather than exposing protobuf types directly on the public API.
Inbound and outbound envelopes are plain
Uint8Arraybuffers on both sides of the orchestratorThese bytes represent:
A serialized
DeRecMessageenvelope, orA plain serialized
ContactMessage(the out-of-band bootstrap, see Pairing)
For encrypted protocol exchanges, the
DeRecMessageenvelope contains an encrypted inner protobuf message — the library performs the encryption, decryption, and framing for you
Applications are responsible for:
Delivering outbound
Uint8Arrayenvelopes — the library passes them toTransport.send(endpoints, message)— to the counterpartyReceiving inbound
Uint8Arrayenvelopes and passing them back throughDeRecProtocol.process(...)
Ids use two conventions, deliberately. Store interfaces (ChannelStore, ShareStore, …), flow params that name a stored record (UnpairParams.channel_id, UnpairReplicaParams.replica_id), and every id field on a DeRecEvent (channel_id, replica_id, secret_id, trace_id) use decimal strings for u64 values, avoiding the precision loss a JS number suffers above 2^53. DeRecProtocol's own methods (createContact, getFingerprint, verifyFingerprint, withReplicaId, …) take bigint | number directly, and Target takes bigints, because those hand the id straight to the native layer.
Keep track of which side of the boundary an id is on. BigInt(event.channel_id) converts one way; String(channelId) the other.
Never JSON.parse and re-serialise stored channel records. ChannelStore.listHelpers / listReplicas must return a JSON array built by splicing the stored bytes as text — every id in those records is a u64, and JSON.parse silently rounds anything above 2^53. The corruption only appears once a real id happens to be large, which is exactly the kind of bug that survives a test suite. See Implementing a persistent store.
Hermes has no TextEncoder / TextDecoder
The Hermes engine React Native ships by default does not implement the WHATWG encoding globals. Node-targeted example code that writes new TextEncoder().encode("...") therefore fails at runtime on device. Supply your own UTF-8 helpers, or install a polyfill:
Every code sample in this section uses utf8Encode / utf8Decode where the Node chapters use TextEncoder / TextDecoder.
Error Handling
All library-side errors surface as a thrown DeRecError:
DeRecError.category identifies the layer or flow that produced the error; code is the specific reason, and one code means the same thing in every category. The exhaustive category list is exported as DEREC_ERROR_CATEGORIES:
Binding-side failures — a malformed argument crossing the C ABI — carry category ffi. The same (category, code) pair carries the same meaning across every language binding — see the cross-binding error catalog.
When process fails
process() settles expired deadlines (sharing-round and unpair timeouts) before it handles the message, and those are never reported again. So when the message then fails, the thrown DeRecError carries them: events holds every event produced before the failure, and channel_id names the channel the message came from (absent when the bytes were not a decodable envelope). Handle the events as you would a successful call's, then the error:
See When process fails.
Differences from the Node package
@derec-alliance/react-native and @derec-alliance/nodejs are kept deliberately source-compatible. These are the places where they are not:
removeExpiredChannels(olderThanSecs: number) (nodejs takes bigint | number | string)
The value is a whole number of seconds; React Native passes it to the native layer as a JavaScript number.
start(FlowKind.Discovery, params?) — params are optional (nodejs requires them)
Omitting them, or omitting target, sends no target and discovers across every paired Helper.
createContact accepts number as well as bigint for channelId and nonce
Both are converted to bigint before they reach the native layer.
The DeRecErrorCategory union differs
It omits "wasm", which a C-ABI binding can never emit, and adds "ok" and "ffi", which it can. A test pins the union to the crate's shared enum fixture so the two cannot drift.
Protocol calls run off the JavaScript thread
Both packages queue calls on one instance. Here the queue is a native worker thread, so a slow store or transport never blocks rendering. See Threading model.
A protocol call from inside a store callback is rejected with an error
In nodejs the same call deadlocks. Here the binding detects it and rejects instead of hanging.
free() drains in-flight work
In nodejs, free() throws while a call is in flight. Here it waits for the worker to finish, then releases the handle and its thread.
No init(); a native rebuild instead
There is no WASM asset to fetch and instantiate (the web package's init() has no counterpart). The cost moves to build time: adding or upgrading the package requires pod install / Gradle sync and a fresh native build.
Everything else — the builder, DeRecEvent, the store interfaces, the flow params, setCommunicationInfo / setOwnTransports (both Promise<void>), primitives.* — is identical.
API Reference
The package ships lib/index.d.ts alongside the JavaScript, and the TypeScript source under src/, so editors and type-checkers surface the full surface natively. Full field-level contracts are documented as TSDoc on each interface in src/types.ts; the published types are the authoritative reference for every method, parameter, and DeRecEvent variant documented in this section.
The package also ships the DeRec .proto schema under schema/ — the flat schema/proto/ files and a precompiled schema/derec_descriptor.bin — so code can be generated for any language without a lib-derec checkout. See Schema distribution.
SDK Architecture
The TypeScript layer turns host functions into typed async methods, marshals Uint8Array / bigint across the boundary, and shapes events into the DeRecEvent union. The C++ layer owns the worker thread and bridges store and transport callbacks back into JavaScript. Below it sits the same compiled derec-library core the .NET and Go SDKs reach through the same C ABI — every binding is a thin translation over the same core.
Reference implementation
A complete on-device integration lives in the monorepo at smoke-tests/react-native/. It is a plain React Native app that runs every flow — pairing (all three contact modes), sharing, verification, discovery, recovery, restore, unpair, update channel info, replica pairing and catch-up, auto-accept, expired-channel cleanup — against in-memory stores and a recording transport, on both a simulator and a device. src/protocol.ts carries the store implementations and the makeNode / processAll / drainOne / doPair / pumpAll helpers the examples in this section refer to; these are smoke-test helpers, not SDK API. src/scenarios.ts additionally covers the two lifetime hazards no other SDK has (freeing a handle with store callbacks outstanding, and calls issued after free()).
Last updated