Skip to main content

Private messaging

Softchat private messages have two distinct layers:

  1. a rumor carries the author, event kind, timestamp, exact tags, content, and deterministic ID without a signature; and
  2. a NIP-59 seal and gift wrap authenticate and privately route that rumor to one recipient.

Never expose a rumor recovered from the network until unwrapGiftWrap authenticates the outer route, outer signature, ciphertext, seal signature, seal shape, rumor ID, and author binding.

Create a one-to-one message

Operation contractprivate.create-message

Create a minimal private kind-14 message rumor.

Inputs and exact bounds
  • exactly one non-self recipient
  • non-empty content with resulting canonical rumor at most 512 KiB
  • explicit timestamp from 0 through 9,007,199,254,740,991
Output
A typed unsigned rumor.
Trust and authentication
Locally created rumor is authored but is not relay-publishable until wrapped.
Stable errors
nip17_message_creation_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
LocalIdentity::create_nip17_text_message

The minimum NIP-17 helper creates kind 14 with exactly one canonical p tag. Group messages, replies, attachments, and extension tags use the richer chat event API.

The lower-level createRumor operation is available when the application has already assembled another accepted event draft:

let message = alice.create_nip17_text_message(
&bob.public_key(),
1_700_000_000,
"hello Bob",
)?;

Wrap recipient and sender copies

Operation contractprivate.wrap

Create an independent NIP-59 copy for one recipient.

Inputs and exact bounds
  • exactly one recipient
  • now from 0 through 9,007,199,254,740,991
  • input rumor and each generated envelope at most 512 KiB
Output
A verified signed durable or ephemeral outer event.
Trust and authentication
Fresh seal and wrapper keys/timestamps are generated for every call.
Stable errors
nip59_envelope_creation_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
LocalIdentity::gift_wrap

Call giftWrap separately for every recipient and for the author’s own recoverable history. Each call creates a fresh wrapper key, exactly one outer p tag, empty seal tags, and independently randomized seal/wrapper timestamps within the previous two days.

let recipient_copy = alice.gift_wrap(
&bob.public_key(),
message.rumor(),
Nip59EnvelopeKind::Durable,
now,
)?;
let sender_copy = alice.gift_wrap(
&alice.public_key(),
message.rumor(),
Nip59EnvelopeKind::Durable,
now,
)?;

Receive and classify

Operation contractprivate.unwrap

Authenticate all NIP-59 layers and classify the rumor.

Inputs and exact bounds
  • one signed kind-1059 or kind-21059 event
  • encoded event at most 512 KiB
Output
An authenticated rumor and envelope metadata.
Trust and authentication
No rumor content is trusted before complete outer, seal, route, and rumor authentication.
Stable errors
invalid_nip59_envelope, invalid_nip17_message · 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
LocalIdentity::unwrap_gift_wrapNip17TextMessage::from_rumor
let envelope = bob.unwrap_gift_wrap(&recipient_copy)?;
let received = Nip17TextMessage::from_rumor(envelope.into_rumor())?;
assert_eq!(received.content(), "hello Bob");

Durable and ephemeral envelopes

WrapperEvent kindUsePersistence
Durable1059messages, account copies, durable changespersist and retry under host policy
Ephemeral21059typing and other transient signalsdo not persist as history

A wrong recipient, altered wrapper, altered seal, or mismatched rumor produces the same stable invalid-envelope failure. Do not reveal which layer failed.