Onym UI ↔ Message: Nostr Implementation
Implementation profile draft 0.1 — August 2026
This profile maps the technology-neutral Onym message boundary onto Nostr events, WebSocket relays, scoped NIP-42 authentication, and Onym-specific topic and inbox conventions.
This document is a concrete implementation of UI-Message.md. The abstract specification remains authoritative for UI-facing operations, delivery meaning, receipts, errors, ownership, and payment isolation. This document defines the Nostr wire mapping and records where the present iOS, Android, and reference-relay code does not yet implement the complete boundary.
The document distinguishes:
- implemented behavior, which exists in the current Onym repositories;
- profile requirements, which are required for a conforming Nostr adapter or relay; and
- gaps, where current code still needs to converge on the profile.
1. Conformance declaration
| Abstract concept | Nostr mapping |
|---|---|
| Courier endpoint | One Nostr relay reached over wss:// |
| Courier component | Signed relay ServiceManifest plus operator identity |
| Topic address | Opaque value in a t tag |
| Inbox address | Opaque value in d, t, and Onym compatibility tags |
| Wire item | NIP-01 event carrying base64 opaque bytes in content |
| Outer integrity | NIP-01 event ID and BIP-340 Schnorr signature |
| Transport message ID | Nostr event id |
| Publish | Client EVENT frame |
| Explicit acceptance | Relay OK with true |
| Subscription | Client REQ; relay EVENT and EOSE; client CLOSE |
| Subscription refusal | Relay CLOSED |
| Access authentication | NIP-42 AUTH using a relay-scoped seat key |
| Paid-service refusal | HTTP 402 before upgrade or Onym payment-required: reason after upgrade |
The implementation profile identifier is:
onym:message-implementation:nostr-courier-v1
It maps this portable profile:
onym:message-profile:opaque-topic-inbox-v1
The event kinds, tags, legacy receive paths, millisecond tag, payment reason,
and ephemeral-key policy are Onym conventions. They are not implied by generic
Nostr support. An adapter claims this implementation profile rather than
inferring compatibility from a wss:// URI.
2. Ownership mapping
- The application-protocol author defines and authenticates the encrypted
bytes placed in event
content. - The Nostr adapter author maps the abstract port to events, filters, frames, acknowledgements, refusal codes, and reconnect behavior.
- The relay operator controls availability, limits, retention, moderation, access policy, offers, and settlement terms.
- The billing broker validates channel purchases and issues or registers a component-scoped entitlement; it does not validate chat messages.
- The UI publisher presents relay selection and compatible offers without becoming the relay operator or group authority.
- The user or group chooses one or more relay components and owns the resulting route.
One party may occupy several seats, but the profile does not merge their authority. A relay's outer event checks do not authorize the inner sender, and an app-store purchase does not authorize a group action.
3. Physical topology
UI flow / repository
|-- application crypto: authenticate and seal envelope
|-- billing coordinator: resolve PaymentRequired
`-- MessageTransport / InboxTransport
|
| opaque bytes + opaque address
v
Nostr message adapter
|
| signed event / filter
v
Nostr relay connection
|
| WSS: EVENT / REQ / AUTH / CLOSE
v
independently owned relay
The current clients connect to every selected relay and publish and subscribe across all of them. Relay fan-out is an availability policy at the route layer. The UI discloses that it also exposes timing, network, routing, and size metadata to every selected operator.
4. Relay declaration
4.1 Target manifest
A conforming relay publishes a signed abstract ServiceManifest whose Nostr
mapping includes at least:
{
"version": 1,
"componentId": "onym:component:<relay-id>",
"seat": "transport.message",
"operator": "onym:key:<operator-id>",
"messageProfileId": "onym:message-profile:opaque-topic-inbox-v1",
"implementationProfileId": "onym:message-implementation:nostr-courier-v1",
"endpoints": [
{"uri": "wss://relay.example", "role": "read-write"}
],
"capabilities": ["topic", "inbox", "nip42", "payment-required-v1"],
"limits": {
"maximumEventBytes": "<declared-limit>",
"maximumWebSocketFrameBytes": "<declared-limit>",
"maximumConcurrentSubscriptions": "<declared-limit>"
},
"retention": "<declared-policy>",
"privacyProfile": "<hash-or-url>",
"entitlementIssuers": ["onym:key:<issuer-id>"],
"offers": ["<relay-offer-id>"],
"validUntil": "2026-12-31T23:59:59Z",
"signature": "<operator-signature>"
}
The client validates the manifest before opening its endpoint. TLS authenticates the current host; the signed manifest binds that host to the selected component, operator, implementation profile, limits, and offers.
4.2 Current endpoint configuration
Current mobile configuration stores only name, url, and isDefault, plus
local state recording whether the user changed the seeded list. Onym's relay
is seeded on first use and every configured relay is selected for fan-out.
This is discovery and preference state, not the complete manifest above.
Display names and shipped defaults do not prove operator identity.
5. Nostr event envelope
A wire event has the NIP-01 fields:
{
"id": "<lowercase-sha256-hex>",
"pubkey": "<32-byte-x-only-public-key-hex>",
"created_at": 1785580800,
"kind": 44114,
"tags": [["t", "<opaque-topic>"], ["ms", "1785580800123"]],
"content": "<base64-opaque-envelope>",
"sig": "<bip340-schnorr-signature-hex>"
}
The event ID is the lowercase SHA-256 digest of the UTF-8 JSON serialization:
[0,"<pubkey>",1785580800,44114,[["t","<topic>"],["ms","1785580800123"]],"<content>"]
Array order, tag order, strings, numbers, and escaping are therefore consensus-sensitive within this implementation. Cross-platform fixtures pin the canonical bytes, ID, and signature.
For message and inbox events, current clients create a fresh secp256k1 signer
for every event. Its x-only public key becomes pubkey, and its BIP-340
signature covers the event ID. This reduces stable sender correlation; it does
not make relay metadata private and it is not an Onym identity or group
signature. The encrypted inner envelope independently authenticates its
sender and meaning.
Every emitted event also carries:
["ms", "<non-negative-Unix-time-in-milliseconds>"]
created_at is the same local clock truncated to seconds. Receivers may use a
valid ms value for display ordering and fall back to created_at * 1000.
Neither value is trusted application order.
6. Topic mapping
6.1 Publish
The adapter emits kind 44114 with a t tag containing the opaque topic and
standard base64 of the sealed bytes in content:
[
"EVENT",
{
"kind": 44114,
"tags": [["t", "<opaque-topic>"], ["ms", "<Unix-time-ms>"]],
"content": "<standard-base64-of-opaque-bytes>"
}
]
The abbreviated object also contains every identity and signature field from
section 5. New adapters emit only kind 44114 under this profile.
6.2 Subscribe and catch-up
The adapter opens one logical subscription per topic and relay:
[
"REQ",
"<unique-subscription-id>",
{
"kinds": [44114, 24114],
"#t": ["<opaque-topic>"],
"since": 1785580500
}
]
Kind 24114 is a receive-only legacy path. When the caller supplies a since
instant, current clients subtract 60 seconds, clamped at zero, to tolerate
clock skew. With no cursor, they request the preceding 300 seconds. These
values describe the current nostr-courier-v1 profile; changing their meaning
requires a new profile version or an explicitly negotiated cursor policy.
The relay returns zero or more EVENT frames and then EOSE for the stored
portion. Live EVENT frames may continue until cancellation. The adapter
sends CLOSE with the exact subscription ID when the stream ends.
7. Inbox mapping
7.1 Send
The adapter emits kind 34113, a parameterized-replaceable event, with this
exact application tag set before the common ms tag is appended:
[
["d", "sep-inbox:<opaque-inbox-id>"],
["t", "<opaque-inbox-id>"],
["sep_inbox", "<opaque-inbox-id>"],
["sep_version", "1"]
]
content is standard base64 of the sealed bytes. d is the canonical
parameterized-replaceable address. The other tags maintain compatibility with
existing Onym and legacy filtering. Because replacement is keyed by kind,
publisher pubkey, and d, the fresh per-event publisher key prevents a new
sender event from replacing another sender's inbox event.
7.2 Subscribe
An inbox subscription carries these three filters in one REQ:
[
"REQ",
"<unique-subscription-id>",
{"kinds": [34113], "#d": ["sep-inbox:<opaque-inbox-id>"]},
{"kinds": [34113], "#t": ["<opaque-inbox-id>"]},
{"kinds": [24113], "#t": ["<opaque-inbox-id>"]}
]
The first filter is canonical, the second is a same-kind compatibility path,
and kind 24113 is receive-only legacy compatibility. One multi-filter REQ
consumes one relay subscription slot and lets a conforming relay suppress an
event that matches more than one filter.
Subscription IDs are at most 64 characters, unique for each subscription
generation, and contain no raw inbox value. A suitable form is a fixed prefix,
a truncated hash of the inbox, and a monotonic local generation. This prevents
an old stream's delayed CLOSE from terminating its replacement and avoids
placing a stable raw address in another frame field.
8. Framing and connection lifecycle
The adapter uses valid Nostr arrays:
| Direction | Frame | Meaning in this profile |
|---|---|---|
| Client → relay | ["EVENT", <event>] |
Submit one topic or inbox event |
| Client → relay | ["REQ", <id>, <filter>...] |
Open a bounded subscription |
| Client → relay | ["CLOSE", <id>] |
Close the named subscription |
| Client → relay | ["AUTH", <event>] |
Answer a NIP-42 challenge |
| Relay → client | ["EVENT", <id>, <event>] |
Deliver a matching event |
| Relay → client | ["EOSE", <id>] |
Mark the stored/live boundary |
| Relay → client | ["OK", <event-id>, <bool>, <reason>] |
Accept or reject an event |
| Relay → client | ["CLOSED", <id>, <reason>] |
Refuse or end a subscription |
| Relay → client | ["NOTICE", <message>] |
Unstructured diagnostic |
| Relay → client | ["AUTH", <challenge>] |
Request NIP-42 authentication |
Unknown or malformed frames are ignored or quarantined within bounded work.
NOTICE is diagnostic text, never a stable domain error or trusted payment
link. OK and CLOSED reasons are parsed only through recognized prefixes;
the original bounded text may be retained as safe diagnostic context.
For every endpoint, a conforming connection:
- establishes TLS and a WebSocket to the manifest-bound
wss://origin; - applies connection and inbound-frame limits;
- authenticates when challenged;
- replays active subscriptions after reconnect;
- uses exponential backoff with jitter and a declared ceiling;
- prevents stale and current receive loops from sharing ownership;
- gives each resubscription a new ID;
- keeps explicit disconnect as sticky intent across delayed reconnect work;
- closes remote subscriptions on cancellation; and
- reports each relay independently.
Current iOS probes liveness every 30 seconds with an impossible REQ and
expects a response because its WebSocket implementation cannot safely rely on
a ping callback; it closes the probe immediately. Android uses OkHttp ping
frames every 30 seconds. Both use reconnect delays from one second up to 120
seconds. These are client choices, not portable message semantics.
9. Validation and inbound delivery
Before producing an abstract inbound value, the adapter:
- bounds the frame and decoded JSON structure;
- validates the frame type and subscription ID;
- parses required event fields with exact types and sizes;
- recomputes and compares the canonical event ID;
- verifies the BIP-340 signature against
pubkey; - requires an allowed kind for the subscription;
- requires the exact requested
tordaddress relation; - enforces manifest event and decoded-payload limits;
- strictly decodes base64;
- suppresses duplicate event IDs; and
- passes only the address, opaque bytes, event ID, relay origin, and observation to the abstract layer.
The application then verifies and decrypts the inner envelope, enforces group or inbox authorization, and deduplicates by authenticated logical message ID. Relay filtering is an optimization: a malicious relay can send an event that does not match the requested filter, so the client repeats the match check.
10. Acceptance and receipts
Explicit relay acceptance is:
["OK", "<event-id>", true, ""]
OK true means only that this relay accepted the event under its declared
service. It is not proof of durable retention, recipient delivery, inner
sender authenticity, group authorization, or global publication.
An OK false is rejected with a typed prefix mapping when recognized. A
network failure before a response is unreachable. A timeout after a send is
unknown, because the relay may have accepted the event before the response
was lost. Silence is never upgraded to accepted.
For fan-out, the adapter returns abstract per-courier outcomes. The same event
ID may be submitted to every relay in one attempt. A logical retry may need a
fresh outer event because time or authorization context changed, while
preserving the inner logicalMessageId for deduplication.
11. Relay authentication
When access control is required, the relay uses NIP-42:
relay -> client: ["AUTH", "<challenge>"]
client -> relay: ["AUTH", <kind-22242-auth-event>]
relay -> client: ["OK", "<auth-event-id>", true, ""]
The authorization event has kind 22242, empty content, a timestamp close
to the challenge, and applicable tags including:
[
["relay", "wss://relay.example"],
["challenge", "<exact-relay-challenge>"]
]
It is signed by a key scoped to this relay component and seat. It is neither the global Onym identity key nor the fresh outer key used for ordinary message events. The relay validates signature, kind, timestamp, challenge, and normalized relay URL, then binds the authenticated subject to the connection.
Because ordinary Onym events deliberately use ephemeral outer keys, a
compatible relay authorizes connection operations for the authenticated seat
and does not require each ordinary event pubkey to equal the NIP-42 access
key. A generic relay configured to require equality is not compatible.
If purchase registration is required, a billing-broker webhook or versioned
relay control endpoint binds the entitlement subject to the relay-scoped
access key. The client never adds proprietary fields to AUTH and never sends
an App Store or Play receipt to the relay.
12. Payment-required mapping
Payment may be refused at two phases:
- Before WebSocket upgrade. HTTPS may return
402 Payment Requiredwith a canonicalPaymentRequireddocument or signed reference. - After WebSocket upgrade. The relay remains inside valid Nostr framing.
It rejects a write with
OK falseor a subscription withCLOSED.
["OK", "<event-id>", false, "payment-required: <opaque-offer-reference>"]
["CLOSED", "<subscription-id>", "payment-required: <opaque-offer-reference>"]
payment-required: is an Onym extension, not a standardized NIP-01 or NIP-42
prefix. Reasons map as follows:
| Prefix | Domain result | Next action |
|---|---|---|
auth-required: |
auth_required |
Answer NIP-42 challenge |
restricted: |
invalid_entitlement or unauthorized |
Refresh entitlement or explain policy |
payment-required: |
payment_required |
Resolve referenced signed offer |
| unknown | generic rejection | Preserve bounded diagnostic text |
The adapter returns PaymentRequired(componentId, offerReference, operation)
to the UI billing coordinator. It does not invoke StoreKit or Play Billing,
select a product, validate a purchase, calculate taxes or commissions, or
declare settlement complete.
application -> adapter: publish or subscribe
application <- adapter: PaymentRequired(relay + offer reference)
UI -> billing channel: purchase approved ChannelOffer product
UI -> billing broker: purchase evidence + relay-scoped access public key
UI <- billing broker: SeatEntitlement
billing broker -> relay control plane: entitlement registration
relay -> client: NIP-42 challenge
client -> relay: NIP-42 proof with relay-scoped key
application -> adapter: retry original logical operation
application <- adapter: per-relay outcome
The retry preserves the sealed envelope and logical message ID. Platform fees, the UI owner's declared commission, taxes, refunds, and settlement to the relay operator remain economic-plane records outside Nostr message events.
13. Delivery, retention, and limits
This profile provides best-effort, at-least-once delivery. Fan-out,
compatibility filters, reconnect, and catch-up can produce duplicates. A relay
can delay, drop, replay, expire, or selectively serve ciphertext. EOSE marks
one relay's stored/live boundary, not globally complete history. The adapter
deduplicates event IDs; the application deduplicates authenticated logical IDs.
Limits are operator declarations, not universal Nostr constants. A client computes the complete UTF-8 JSON event size after base64 expansion and event overhead, rather than comparing only the opaque payload length.
The current strfry reference deployment is configured with:
| Limit | Current value |
|---|---|
| Maximum event size | 65,536 bytes |
| Maximum WebSocket payload | 131,072 bytes |
| Maximum concurrent subscriptions per connection | 20 |
| Relay automatic ping interval | 55 seconds |
Current iOS and Android parsers reject inbound relay frames above 1,048,576
bytes. That defensive ceiling is larger than the reference relay's limit and
is not a promise that a relay will accept such an event. The target manifest
allows rejection before signing or network work. Queues, pending OK waiters,
tags, subscriptions, reconnect tasks, and diagnostics also require bounds.
14. Error mapping
| Abstract error | Nostr source |
|---|---|
unsupported_profile |
Manifest lacks the profile or capability |
invalid_endpoint |
Invalid origin, manifest mismatch, or TLS failure |
not_connected |
No selected connection available |
unreachable |
DNS, TLS, WebSocket, send, or connection timeout |
auth_required |
AUTH challenge or auth-required: refusal |
payment_required |
HTTP 402 or Onym payment-required: refusal |
invalid_entitlement |
restricted: after attempted seat authentication |
rate_limited |
Profile-mapped rate-limit refusal |
payload_too_large |
Local manifest limit or relay rejection |
publish_rejected |
OK false without a specific mapping |
subscription_rejected |
CLOSED without a specific mapping |
outcome_unknown |
Submitted event without conclusive OK |
invalid_frame |
Malformed, excessive, or unexpected event/filter relation |
invalid_outer_integrity |
Event-ID or Schnorr verification failure |
retention_gap |
Catch-up cursor cannot be completely served |
Free-form NOTICE, OK, and CLOSED strings stay diagnostics. They cannot
become executable links, product identifiers, or authorization decisions
without resolving a signed, profile-bound object.
15. Current implementation mapping
| Boundary role | Current implementation |
|---|---|
| Common message port | Transport.swift in onym-ios; Transport.kt in onym-android |
| Topic adapter | NostrMessageTransport in both clients |
| Inbox adapter | NostrInboxTransport in both clients |
| Connection and framing | NostrRelayConnection and NostrEvent in both clients |
| Outer signing | OnymNostrSigner and NostrEphemeralSignerProvider |
| Relay selection | NostrRelaysRepository and platform selection stores |
| Reference relay | strfry behind Caddy in onym-infra |
Implemented today on both platforms:
- topic kind
44114emission and kinds44114/24114receipt; - inbox kind
34113, its four application tags, and kind24113fallback; - base64 payloads, the
mstag, canonical event IDs, and ephemeral signing; - multi-relay connection and fan-out;
- bounded inbound frames, reconnect backoff, heartbeat, subscription replay, and event-ID checking; and
- mirrored event and filter-construction tests.
16. Known gaps
Current code is not yet a complete conforming implementation:
- the receipt exposes only
messageIdand aggregateacceptedBy, not per-relay accepted, rejected, unreachable, and unknown outcomes; - topic publish counts a local WebSocket send path rather than
OK true; Android can count its best-effort call even when no socket sends; - inbox publish treats five seconds without
OKas success rather thanoutcome_unknown; OKreasons are discarded, andAUTHandCLOSEDare ignored, so authentication and payment refusal cannot reach the UI;- clients recompute event IDs but do not consistently perform full Schnorr verification or independently validate kind/address match;
- Android inbox subscriptions use three separate
REQframes with raw inbox-based IDs, while iOS uses one multi-filterREQ, hashed IDs, and generation-safe cancellation; - Android reconnect work lacks iOS's sticky
desiredConnectedguard, so a delayed failure can reconnect after explicit disconnect; - topic catch-up uses time windows rather than a portable cursor, and inbox catch-up has no explicit cursor;
- relay configuration does not bind operator, limits, retention, privacy, offers, or entitlement issuers;
- payload limits are not derived from a verified manifest before event construction;
PaymentRequired, NIP-42 seat authentication,SeatEntitlement, renewal, revocation, quota accounting, and broker registration are not implemented; and- conformance relies on mirrored app tests rather than one fixture suite for third-party clients and relays.
These gaps are interoperability and security work, not alternate wire semantics that third parties should copy.
17. Conformance tests
A shared fixture suite covers:
- exact topic and inbox event bytes, IDs, and signatures;
- base64 vectors including empty, maximum, and malformed payloads;
msparsing andcreated_atfallback;- topic catch-up boundary and 60-second slack;
- all inbox filters in one
REQand receive-only legacy kinds; - event-ID, signature, kind, tag, address, and size rejection;
EVENT,EOSE,OK,CLOSED,NOTICE, andAUTHparsing;- NIP-42 challenge binding, origin scoping, expiry, and key isolation;
- pre-upgrade 402 and post-upgrade payment refusal mapping;
- acceptance, rejection, timeout-after-send, and partial fan-out;
- reconnect, replay, unique generations, cancellation, and sticky disconnect;
- cross-relay event and inner logical-message deduplication; and
- manifest signature, limit, offer, origin, and expiry validation.
18. Acceptance criteria
This Nostr profile satisfies the abstract boundary when:
- a third-party adapter can implement it without inspecting mobile code;
- a third-party relay can serve paid or free access without becoming an Onym identity or group authority;
- events, keys, tags, filters, frames, and refusal strings remain behind the abstract message port;
- every relay outcome records exactly what its response proved;
- authentication uses a relay-scoped seat key without sacrificing per-message ephemeral outer keys;
- payment remains valid Nostr framing and never carries store evidence;
- hostile relay frames cannot bypass outer and inner validation or exhaust unbounded client resources;
- reconnect, catch-up, duplicates, compatibility filters, and partial fan-out pass shared fixtures; and
- replacing Nostr does not alter abstract operations or the encrypted Onym envelope.
References
- Abstract Onym message boundary: UI-Message.md
- Onym iOS client: https://github.com/onymchat/onym-ios
- Onym Android client: https://github.com/onymchat/onym-android
- Onym infrastructure: https://github.com/onymchat/onym-infra
- Nostr NIP-01, “Basic protocol flow description”: https://github.com/nostr-protocol/nips/blob/master/01.md
- Nostr NIP-42, “Authentication of clients to relays”: https://github.com/nostr-protocol/nips/blob/master/42.md
This document is maintained in the public onym-system repository and rendered here from a pinned commit. The repository is the authoritative source — latest version on main →