Private messaging
Softchat private messages have two distinct layers:
- a rumor carries the author, event kind, timestamp, exact tags, content, and deterministic ID without a signature; and
- 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
private.create-messageCreate 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
- Rust
- Android
- Swift
- TypeScript
LocalIdentity::create_nip17_text_messageSoftchatIdentity.createNip17TextMessageSoftchatIdentity.createNip17TextMessageSoftchatIdentity.createNip17TextMessageThe 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:
- Rust
- Android
- Swift
- TypeScript
- Rust
- Android
- Swift
- TypeScript
let message = alice.create_nip17_text_message(
&bob.public_key(),
1_700_000_000,
"hello Bob",
)?;
val message = alice.createNip17TextMessage(
recipient = bob.publicKey,
createdAt = 1_700_000_000,
content = "hello Bob",
)
The returned rumor is not ready for relay publication; wrap a distinct recipient copy and sender history copy first.
let message = try alice.createNip17TextMessage(
for: bob.publicKey,
createdAt: 1_700_000_000,
content: "hello Bob"
)
const message = alice.createNip17TextMessage(
bob.publicKey,
1_700_000_000,
"hello Bob",
);
- Rust
- Android
- Swift
- TypeScript
Wrap recipient and sender copies
private.wrapCreate 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
- Rust
- Android
- Swift
- TypeScript
LocalIdentity::gift_wrapSoftchatIdentity.giftWrapSoftchatIdentity.giftWrapSoftchatIdentity.giftWrapCall 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.
- Rust
- Android
- Swift
- TypeScript
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,
)?;
val recipientCopy = alice.giftWrap(
bob.publicKey, message.rumor, Nip59EnvelopeKind.DURABLE, now
)
val senderCopy = alice.giftWrap(
alice.publicKey, message.rumor, Nip59EnvelopeKind.DURABLE, now
)
Persist both publish intents before handing frames to a WebSocket. Durable kind 1059 is retryable and persistable; ephemeral kind 21059 is not durable history.
let recipientCopy = try alice.giftWrap(
message.rumor, for: bob.publicKey, kind: .durable, now: now
)
let senderCopy = try alice.giftWrap(
message.rumor, for: alice.publicKey, kind: .durable, now: now
)
const recipientCopy = alice.giftWrap(
bob.publicKey, message.rumor, "durable", now,
);
const senderCopy = alice.giftWrap(
alice.publicKey, message.rumor, "durable", now,
);
- Rust
- Android
- Swift
- TypeScript
Receive and classify
private.unwrapAuthenticate 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
- Rust
- Android
- Swift
- TypeScript
LocalIdentity::unwrap_gift_wrapNip17TextMessage::from_rumorSoftchatIdentity.unwrapGiftWrapNip17TextMessage.fromRumorSoftchatIdentity.unwrapGiftWrapNip17TextMessage.init(rumor:)SoftchatIdentity.unwrapGiftWrapNip17TextMessage.fromRumor- Rust
- Android
- Swift
- TypeScript
let envelope = bob.unwrap_gift_wrap(&recipient_copy)?;
let received = Nip17TextMessage::from_rumor(envelope.into_rumor())?;
assert_eq!(received.content(), "hello Bob");
val envelope = bob.unwrapGiftWrap(recipientCopy)
val received = Nip17TextMessage.fromRumor(envelope.rumor)
check(received.content == "hello Bob")
For production ingestion, pass the signed outer event to
account.incoming.ingest(listOf(recipientCopy), receivedAt). Rust unwraps,
authenticates, classifies, and commits exact truth plus projections in one
SQLite transaction. Confirm relay ingestion only after that call succeeds.
let envelope = try bob.unwrapGiftWrap(recipientCopy)
let received = try Nip17TextMessage(rumor: envelope.rumor)
precondition(received.content == "hello Bob")
const envelope = bob.unwrapGiftWrap(recipientCopy);
const received = Nip17TextMessage.fromRumor(envelope.rumor);
console.assert(received.content === "hello Bob");
- Rust
- Android
- Swift
- TypeScript
Durable and ephemeral envelopes
| Wrapper | Event kind | Use | Persistence |
|---|---|---|---|
| Durable | 1059 | messages, account copies, durable changes | persist and retry under host policy |
| Ephemeral | 21059 | typing and other transient signals | do 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.