Noise and synchronization
The SDK implements the exact deployed
Noise_NK_25519_ChaChaPoly_SHA256 profile and bounded NIP-77 Negentropy state.
The standalone primitives never open a socket. Native consumers can enable
managed-transport; Android starts it through
SoftchatAccount.startManagedTransport, including bounded SQLite fingerprint
pages, fetched-event transactions, and the durable checkpoint.
For Noise endpoints the managed transport advertises and requires the deployed
WebSocket subprotocol nostr-noise-nk.v1.25519-chachapoly-sha256 before the
binary handshake. Custom socket implementations must negotiate the same token.
Noise handshake and transport
noise.transportAuthenticate the deployed Noise NK relay profile and transport frames.
- Inputs and exact bounds
- exact 32-byte relay static key
- frame plaintext at most 65,519 bytes and ciphertext at most 65,535 bytes
- chunked logical message at most 512 KiB
- Output
- A single-use handshake then authenticated transport.
- Trust and authentication
- No transport plaintext is exposed before frame authentication succeeds.
- Stable errors
invalid_noise_handshake,noise_handshake_consumed,noise_session_closed,noise_transport_failed· handling and retry rules
Ownership, lifecycle, and execution rules
- Ownership
- Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
- Retry
- Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
- Lifecycle
- Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
- Concurrency
- Serialize mutable session capabilities; immutable values follow the language type system.
- Cancellation
- The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
- Rust
- Android
- Swift
- TypeScript
NoiseClientHandshakeNoiseTransportNoiseClientHandshakeNoiseTransportNoiseClientHandshakeNoiseTransportNoiseClientHandshakeNoiseTransportCreate one handshake with the relay’s authenticated static public key. Send
messageOne as one binary WebSocket frame, pass the relay’s complete message
two to complete, then use the returned transport for ordered binary frames.
- Rust
- Android
- Swift
- TypeScript
let handshake = NoiseClientHandshake::new(relay_static_key, true)?;
socket.send_binary(handshake.message_one());
let transport = handshake.complete(message_two)?;
for frame in transport.encrypt(application_bytes)? {
socket.send_binary(frame);
}
if let Some(message) = transport.decrypt(received_frame)? {
handle_complete_message(message);
}
transport.shutdown();
val transport = NoiseClientHandshake(relayStaticKey).use { handshake ->
socket.send(handshake.messageOne)
handshake.complete(receiveBinaryFrame())
}
transport.use {
it.encrypt(applicationBytes).forEach(socket::send)
}
Call encrypt for one logical message and send every returned frame in order.
decrypt returns null until a chunked message is complete.
The standalone recipe remains available for a custom socket. The standard
account runtime instead owns the handshake and WebSocket inside
ManagedAccountTransport; call stop() on account loss and never retain it
across an account switch.
let handshake = try NoiseClientHandshake(
relayStaticPublicKey: relayStaticKey
)
try await socket.send(.data(handshake.messageOne))
let transport = try handshake.complete(messageTwo: await receiveData())
The actor sends every result from transport.encrypt in order and waits for a
non-nil result from decrypt before decoding an application message.
const handshake = new NoiseClientHandshake(relayStaticKey);
socket.send(handshake.messageOne);
const transport = handshake.complete(messageTwo);
for (const frame of transport.encrypt(applicationBytes)) socket.send(frame);
const complete = transport.decrypt(receivedFrame);
transport.shutdown();
Perform synchronous crypto in a Worker when messages are large.
Authentication failure closes this session. Never retry with the same handshake state, reveal detailed cryptographic errors, or concatenate transport frames outside the negotiated chunking rules.
Negentropy
sync.negentropyReconcile bounded immutable event snapshots with NIP-77.
- Inputs and exact bounds
- at most 100,000 items
- frame size from 4,096 through 512 KiB
- Output
- Bounded outgoing frames and have/need event ids.
- Trust and authentication
- The standalone client owns only reconciliation state; Android account transport reads and commits through the Rust-owned database.
- Stable errors
invalid_negentropy· handling and retry rules
Ownership, lifecycle, and execution rules
- Ownership
- Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
- Retry
- Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
- Lifecycle
- Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
- Concurrency
- Serialize mutable session capabilities; immutable values follow the language type system.
- Cancellation
- The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
- Rust
- Android
- Swift
- TypeScript
NegentropyClientAccountSynchronization.observeAccountSynchronization.resetAccountTransport.startAccountTransport.applyNegentropyClientNegentropyClientThe standalone API takes one bounded, sorted snapshot of (createdAt, eventId)
pairs. Android uses the account managed transport instead: Rust pages the
account fingerprints, drives Negentropy, authenticates and commits fetched
events, saves the checkpoint, and executes its correlated WebSocket actions.
- Rust
- Android
- Swift
- TypeScript
let client = NegentropyClient::new(items, 60_000)?;
send(client.initiate()?);
let step = client.reconcile(response)?;
val progress: Flow<SynchronizationState?> = account.sync.observe()
val transport = account.startManagedTransport(context)
// Explicit recovery keeps authenticated events and rebuilds checkpoints.
transport.forceResync()
Do not build a Room snapshot or store a second checkpoint. Rust pages account fingerprints, drives Negentropy, correlates requested IDs, commits authenticated fetches, and saves checkpoints through the account transport.
let client = try NegentropyClient(items: items)
send(try client.initiate())
let step = try client.reconcile(response)
const client = new NegentropyClient(items);
send(client.initiate());
const step = client.reconcile(response);
client.close();
The snapshot and frame limits are denial-of-service boundaries. Split the application’s sync scope instead of raising them without a measured, interoperable protocol decision.