Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
28 KiB
NIP-RS
Cross-Device Read State Sync
draft optional
Abstract
This NIP defines a scheme for synchronizing a user's own per-context read state
(e.g., "I have read this channel up to timestamp T") across multiple client
instances belonging to that same user, using encrypted kind:30078 events.
This NIP is not a read-receipt protocol. It does not expose what another user has read, and it does not tell other users what messages the current user has read.
Motivation
A user running Nostr clients on multiple devices (phone, desktop, web) has no way to share read position across those clients. Each instance independently tracks what has been read, causing already-read content to appear unread on other devices.
This NIP defines a minimal, privacy-preserving protocol for propagating read state across client instances without requiring relay-side logic or coordination between different client implementations.
Non-Goals
This NIP does not define a durable log of all read messages — blobs are best-effort recent activity hints bounded by a time horizon.
This NIP does not define cross-client interoperability on context ID format — context identifiers are opaque by default and meaningful only within a single client family, except for OPTIONAL well-known schemes defined in this NIP (thread:<root-event-id> and msg:<event-id>, defined under Read Context Schemes), which are provided for cross-client thread/message-read interoperability.
This NIP does not define mark-as-unread — the merge rule is monotonic by design.
This NIP does not guarantee ordering of read events across devices.
This NIP does not require relay-side logic.
This NIP does not define read receipts, seen-by lists, or any mechanism for
tracking what other users have read.
Specification
Event Structure
Clients publish a kind:30078 addressable event (per NIP-78) with the following structure:
{
"kind": 30078,
"pubkey": "<user-pubkey>",
"created_at": 1700000000,
"tags": [
["d", "read-state:<slot-id>"],
["t", "read-state"]
],
"content": "<nip44-encrypted-json>"
}
d Tag
The d tag MUST be read-state:<slot-id>, where <slot-id> is a random opaque string (e.g., 32 random hex characters) generated by the client on first launch and persisted locally. The <slot-id> has no relationship to the client_id — it is solely a unique key for NIP-33 addressable event semantics. Each client instance MUST use a stable, unique <slot-id> for the lifetime of that installation.
If a client fetches its own d tag coordinate and the decrypted client_id does not match its local client_id, the coordinate is conflicted. The client MUST NOT publish to that coordinate and MUST generate a new random <slot-id> before the next publish.
Events with zero d tags MUST be ignored.
Events whose d tag value does not begin with read-state: MUST be ignored.
Events with more than one d tag MUST be ignored.
The <slot-id> MUST be a non-empty ASCII string of 1–64 characters.
t Tag
Events MUST include exactly one ["t", "read-state"] tag. This enables relay-side filtering without fetching all kind:30078 events for the user.
Events with zero t tags with value read-state, or more than one t tag with value read-state, MUST be ignored.
Content
The content field MUST be a NIP-44 ciphertext. The NIP-44 conversation key MUST be computed as nip44_conversation_key(user_privkey, user_pubkey) — the user's private key as the local party and their own public key as the remote party.
The plaintext MUST be a JSON object of the following form:
{
"v": 1,
"client_id": "<client-id>",
"contexts": {
"<context-id>": <unix-timestamp>
}
}
vis an integer schema version. Clients MUST ignore blobs with unknownvvalues.client_idis a non-empty UTF-8 string of 1–64 characters identifying this client instance. It is generated on first launch and persisted locally. Each client instance MUST use a stable, uniqueclient_id. This field is the only link between a blob and the device that owns it; it is never visible to relay operators.- Keys under
contextsare arbitrary UTF-8 strings identifying a readable context (e.g., a channel, group, or conversation). This NIP does not prescribe context identifier format. - Values are unix timestamps (integer seconds) representing "all messages in this context at or before this time have been read."
Unknown top-level keys in the JSON object SHOULD be ignored for forward compatibility.
Content Validation
After decryption, clients MUST apply the following validation rules:
- Events whose
contentdoes not decrypt to valid JSON MUST be discarded. - Events with a missing or non-integer
vfield MUST be discarded. - Events with an unknown
vvalue MUST be ignored. - Events with a missing
client_idfield MUST be discarded. - Events with a
client_idthat is not a non-empty string of 1–64 UTF-8 characters MUST be discarded. - Events with a missing
contextsfield MUST be discarded. - Events whose
contextsfield is not a JSON object MUST be discarded. - Individual context entries whose timestamp is not an integer in the range 0–4294967295 MUST be discarded (the entry is dropped; the rest of the blob is still processed).
- Individual context entries whose context ID exceeds 256 bytes MUST be discarded.
- Blobs containing more than 10,000 context entries MUST be rejected.
- If a blob contains duplicate context keys, clients SHOULD use the last value encountered (consistent with RFC 8259 §4).
- Clients SHOULD ensure the total serialized event does not exceed the relay's maximum event size (commonly 64 KB per NIP-01). Clients receiving events that exceed their configured size limit SHOULD discard them.
Context Identifiers
Context identifier format is not prescribed by this NIP. Clients choose identifiers appropriate to their context type (e.g., a NIP-28 channel event ID, a NIP-29 group address, a pubkey for DMs). Interoperability between different client implementations on context ID conventions is outside the scope of this NIP.
Read Context Schemes (Optional)
This subsection defines OPTIONAL well-known context schemes for tracking read
state below a channel: thread-level read state for reply chains and per-message
read state for individual events. These schemes are pure interpretation layers
over the flat contexts map — they introduce no new fields, no nesting, and no
change to the merge rule, event structure, validation, or fetching. The schema
version v remains 1. Clients that do not implement this subsection remain
fully interoperable (see Backwards Compatibility below).
A client implementing thread read contexts MUST use the context key
thread:<root-event-id> for a thread, where <root-event-id> is the
64-character lowercase hex event ID of the thread's root event.
A client implementing per-message read contexts MUST use the context key
msg:<event-id> for a message, where <event-id> is the 64-character lowercase
hex event ID of that message. Per-message contexts are useful for clients that
reveal only part of a thread (for example, a thread panel with collapsed nested
branches): a client can mark the revealed reply events read without marking the
whole thread read.
A bare channel identifier (e.g., the NIP-28 channel event ID) remains the channel context, exactly as before — this is grandfathered existing behavior and is unchanged.
Keys beginning with thread: whose remainder does not match ^[0-9a-f]{64}$
MUST be treated as ordinary opaque contexts, not as thread contexts. Keys
beginning with msg: whose remainder does not match ^[0-9a-f]{64}$ MUST be
treated as ordinary opaque contexts, not as per-message contexts. This protects
an existing client family that may already use one of these prefixes from being
misinterpreted under this scheme.
The relationship between a thread or message and its parent channel is DERIVED
from the Nostr event graph at evaluation time (the root/message event's channel
reference, e.g. its h tag) and MUST NOT be serialized into the blob. The blob
remains a flat {<context-id>: <unix-timestamp>} map.
Hierarchical Frontier Rule
The effective read frontier of a context is the maximum of its own merged timestamp and the effective frontier of its parent:
effective(ctx) = max(merged[ctx], effective(parent(ctx)))
A channel has no parent, so its effective frontier is simply its own merged value. For a thread, the parent is its channel:
effective(thread:<root>) = max(merged[thread:<root>], merged[<channelId>])
For a per-message context, the parent is also its channel (not its thread or parent message):
effective(msg:<event-id>) = max(merged[msg:<event-id>], merged[<channelId>])
When a surface evaluates a reply inside a known thread, it MAY additionally fold in the thread frontier:
effective(reply) = max(effective(msg:<reply-id>), effective(thread:<root>))
A thread is unread iff at least one reply is unread. A reply is unread iff
reply.created_at > effective(reply) for clients that implement per-message
contexts; clients that only implement thread contexts MAY instead use
latestReplyAt > effective(thread:<root>). Because both rules are max() over
the same grow-only registers defined in the Merge Rule, they remain monotone
state-based CvRDT interpretations — no change to the merge rule is required.
Marking a channel read clears unread state on any thread/message whose relevant
event predates the channel frontier, since each child context inherits the
channel term; replies newer than the channel frontier remain unread until their
own message marker or thread marker is advanced.
If the thread root or message event (and therefore its parent channel) cannot be
resolved from the event graph, effective(thread:<root>) or
effective(msg:<event-id>) degrades to its own merged value alone.
Write Discipline
Marking a thread read MUST advance only its own thread:<root> context.
Marking an individual message read MUST advance only its own msg:<event-id>
context. Neither operation may advance the parent channel context. Otherwise,
reading a single thread or reply would silently mark later top-level channel
messages as read. Marking a channel read advances only the channel context
(which the hierarchical rule then propagates to child contexts at read time).
The channel context SHOULD advance to the maximum created_at across the
channel's top-level messages only, NOT including thread replies. This keeps a
thread unread when its replies exceed the newest top-level message: opening a
channel clears the channel timeline but leaves its threads/replies unread until
each thread or message is read.
Eviction
A thread:<root> or msg:<event-id> entry whose value is
<= effective(parent) is semantically inert: the parent (channel) frontier
already covers it, so its presence or absence does not change the result of the
child context's effective frontier. Clients MAY drop such dominated entries
before publishing to bound blob size, consistent with the Debounce and Pruning
section.
This eviction is bounded best-effort, NOT a guaranteed garbage-collection or
per-key tombstone mechanism. Because the merge rule re-merges any context
present in another instance's blob (see Merge Rule and Live Subscription and
Convergence), a dropped thread:<root> or msg:<event-id> key MAY be
re-added by a peer instance that still carries it. A dropped key stays gone only
once it is dominated on every instance or has aged past the time horizon
everywhere. Clients SHOULD treat child-context eviction as a companion to the existing time-horizon
pruning, not as a standalone guarantee that the context count or blob size will
shrink immediately.
To avoid a re-publish loop with peers that still carry an evicted key, an
incoming context entry whose value is <= effective(parent(ctx)) MUST NOT by
itself trigger a re-publish. Clients SHOULD evaluate the Live Subscription
re-publish trigger and the suppression comparison (Live Subscription and
Convergence rules 2–3) AFTER applying their eviction policy, so that re-merging
a dominated key a peer still carries does not force a write that changes nothing
semantically. This is backwards-safe: it only suppresses writes with no semantic
effect.
Backwards Compatibility
A client that does not implement this scheme treats thread:<root> and
msg:<event-id> keys as ordinary opaque contexts. It carries the keys through
the merge unchanged (already required by the Merge Rule) and simply computes no
thread/message-level unread state. There is no validation change and no interop
break: an unaware client and an aware client can share a blob and both produce
correct results for the contexts they understand.
Example
Two blobs merge to the following effective state for a symbolically named thread
X and its parent channel (real thread: keys use 64-character lowercase hex
event IDs):
{
"thread:X": 100,
"<channelId>": 150
}
The thread's effective frontier is computed through the channel parent term:
effective(thread:X) = max(merged[thread:X], merged[<channelId>])
= max(100, 150) = 150
A thread reply with created_at = 140 is <= 150, so it reads as read (the
channel frontier already covers it). A reply with created_at = 160 is > 150,
so the thread reads as unread. The thread's own entry (100) is dominated by
the channel frontier (150) and is therefore inert — a client MAY evict it
before publishing. The same rule applies to msg:<event-id> entries for
individual replies.
Timestamp Accuracy
Clients SHOULD use the created_at of the message being marked as read as the context timestamp — not the local wall clock and not the relay receive time.
Clients SHOULD ensure timestamps within a context are monotonically non-decreasing.
Because context timestamps are derived from message created_at values — which are author-controlled in Nostr — a message with a future-dated or skewed created_at can advance the read frontier beyond the actual read position. This is an accepted limitation of timestamp-based read state. Clients MAY implement local safeguards such as capping context timestamps at the current wall clock time, but this NIP does not mandate such behavior.
Fetching
To load read state, a client MUST fetch all kind:30078 events for the user within the time horizon using the #t filter:
{"kinds": [30078], "authors": ["<user-pubkey>"], "#t": ["read-state"], "since": <now - horizon>}
Clients SHOULD limit the fetch to events with created_at within a configurable time horizon (default: 7 days).
After fetching, clients MUST:
- Decrypt each blob.
- Discard blobs that fail validation (see Content Validation).
- Identify the blob whose decrypted
client_idmatches the client's ownclient_id— this is the client's own blob.
If multiple blobs decrypt to the same client_id (e.g., due to a prior rotation that left an orphaned blob, or a backup/restore that duplicated identifiers), the client MUST treat the blob with the highest created_at as its own and merge all others into the read state as if they were from other instances. The client SHOULD delete the stale duplicate(s) via NIP-09 deletion.
- Merge all valid blobs (including the client's own) using the merge rule.
Absence of a context in all fetched blobs means the read state for that context is unknown — clients SHOULD treat unknown contexts as unread (conservative default). The horizon is a storage and fetch optimization, not a semantic claim about read status. Contexts that were read but have aged out of the time horizon are indistinguishable from never-read contexts. Clients MAY extend the horizon or maintain a local cache to mitigate this.
Merge Rule
After decrypting all fetched blobs, the effective read timestamp for each context is:
effective[context] = max(timestamp) across all blobs
This is a grow-only max-register state-based CvRDT with an associative, commutative, idempotent join. Clients MUST NOT lower a read timestamp — only advance it.
Writing
Clients MAY publish read state automatically when read-position sync is part of the client's default account state model. This NIP is explicitly not a read-receipt protocol; any protocol or feature that exposes what a user has read to other users MUST require explicit user consent.
Clients SHOULD publish read state blobs to the same relays they use for general event storage. Clients that implement NIP-65 (relay list metadata) SHOULD publish to their write relays and fetch from their read relays.
Each client instance maintains its own blob (one kind:30078 event per <slot-id>). Writing replaces the previous blob via parameterized replaceable event semantics (NIP-33).
Clients MUST only update the blob whose decrypted client_id matches their own client_id. Clients MUST NOT overwrite another instance's blob.
If the client discovers multiple blobs with its own client_id during a fetch, it MUST select the one with the highest created_at as its active blob and SHOULD delete the others.
Read-Before-Write
Before publishing, a client MUST:
- Fetch its own current blob from each relay it intends to publish to, and merge all fetched versions.
The client fetches its own blob using its known d tag value:
{"kinds": [30078], "authors": ["<user-pubkey>"], "#d": ["read-state:<own-slot-id>"]}
- Decrypt and merge the fetched blob with local state using
max()per context. - Publish the merged result.
If a relay is unreachable during the fetch step, the client SHOULD proceed with the data available from reachable relays. The merge rule ensures that data from the unreachable relay will be incorporated on the next successful fetch, provided the relay retains the event. Permanent relay loss or event expiry may result in state loss — this is an accepted property of the best-effort model (see Non-Goals).
This read-before-write requirement also applies to re-publishes triggered by incoming blobs from other instances (see Live Subscription and Convergence).
The created_at monotonicity rule applies relative to the maximum created_at seen across all fetched blobs. Combined with the max-merge rule, this reduces the risk of state loss when two instances write concurrently. Full consistency is achieved once all instances complete a subsequent fetch-merge-publish cycle.
Live Subscription and Convergence
Clients SHOULD subscribe to kind:30078 events for their own pubkey with #t: ["read-state"] for live updates:
{"kinds": [30078], "authors": ["<user-pubkey>"], "#t": ["read-state"]}
When a blob from another client instance arrives (i.e., its decrypted client_id does not match the client's own client_id):
- Merge it into local state using
max()per context. - If any context timestamp in the incoming blob is greater than the corresponding timestamp in the client's last-published blob (or the context is absent from the last-published blob), perform a read-before-write and re-publish the client's own blob after a debounce delay.
- Clients MUST suppress the re-publish if the merged result is identical to the client's last-published blob. A client that has never published treats its last-published blob as empty.
- Clients SHOULD limit re-publishes triggered by incoming blobs to at most one per debounce window, regardless of how many blobs arrive during that window.
This drives convergence without a coordination round-trip, assuming eventual relay reachability and event retention.
Clock Skew
When publishing, if the client's local clock produces a created_at value less than or equal to the maximum created_at seen across all fetched blobs for the same d tag, the client MUST use max_fetched_created_at + 1 instead.
Debounce and Pruning
Clients SHOULD debounce writes to avoid excessive relay traffic (e.g., flush 5–10 seconds after the last local read-state change, or on app close/background transition). Clients MUST NOT write on every individual read action.
The blob SHOULD contain only contexts the client has explicitly interacted with. Clients SHOULD prune aggressively, prioritizing recently-active contexts, and MAY drop entries older than the time horizon before writing. Clients MUST ensure the published event does not exceed relay event size limits (typically 64 KB content).
Client-ID Rotation
Clients MAY rotate their client_id by generating a new one, generating a new random <slot-id>, and publishing a new blob. The old blob becomes orphaned and ages out of the time horizon naturally. Rotation adds one extra blob temporarily. Clients SHOULD keep their client_id stable for as long as possible to minimize blob proliferation.
If a device backup or clone results in two installations sharing the same client_id and slot-id, both will write to the same blob. This is operationally equivalent to a single client and does not corrupt state, but the two installations will overwrite each other's context entries. Clients that detect this condition (e.g., by observing unexpected context changes in their own blob) SHOULD generate a new client_id and slot-id.
Orphaned Blob Deletion
Clients MAY delete blobs from decommissioned client instances by publishing a kind:5 deletion event per NIP-09 targeting the orphaned event's a tag coordinate (30078:<pubkey>:<d-tag-value>). This is optional — orphaned blobs are harmless and age out naturally.
Example
A user runs two clients: a desktop app and a mobile app. Each has a random <slot-id> with no relationship to its client_id.
Desktop blob (d tag: read-state:a3f8c2e1d4b7906f5e2a1c8d3b6e9f04), decrypted content:
{
"v": 1,
"client_id": "desktop-v2-prod",
"contexts": {
"ctx:AAA": 1700000100,
"ctx:BBB": 1700000050
}
}
Mobile blob (d tag: read-state:7b1d5a3e9c2f804d6e1b3a7c5d8f2e06), decrypted content:
{
"v": 1,
"client_id": "mobile-ios-v1",
"contexts": {
"ctx:AAA": 1700000200,
"ctx:CCC": 1700000080
}
}
The d tag slot IDs are random and reveal nothing about the client identity. The client_id values inside the encrypted content identify which device owns each blob.
Merged effective state:
{
"ctx:AAA": 1700000200,
"ctx:BBB": 1700000050,
"ctx:CCC": 1700000080
}
Test Vectors
The following vectors show plaintext content only. Actual events would carry NIP-44 ciphertext in the content field. The slot IDs in the d tags are random and have no relationship to the client_id values.
Device A — plaintext content
{
"v": 1,
"client_id": "client-aabbccdd",
"contexts": {
"group:general": 1700001000,
"group:dev": 1700000500
}
}
Event tags:
[
["d", "read-state:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d"],
["t", "read-state"]
]
Device B — plaintext content
{
"v": 1,
"client_id": "client-11223344",
"contexts": {
"group:general": 1700001200,
"group:random": 1700000800
}
}
Event tags:
[
["d", "read-state:f0e1d2c3b4a5968778695a4b3c2d1e0f"],
["t", "read-state"]
]
Merged effective state
{
"group:general": 1700001200,
"group:dev": 1700000500,
"group:random": 1700000800
}
Device A's own blob is identified because its decrypted client_id (client-aabbccdd) matches Device A's locally stored client_id. Device B's blob is merged but not overwritten by Device A.
Ciphertext Test Vector
The following vector demonstrates the full encrypt-to-self pipeline using NIP-44 v2. The private key is the well-known secp256k1 scalar 1.
private_key = 0000000000000000000000000000000000000000000000000000000000000001
public_key = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
Plaintext:
{"v":1,"client_id":"test-vector-client","contexts":{"group:general":1700001000,"group:dev":1700000500}}
Ciphertext (NIP-44 v2, base64):
Akt10yui5aDIjfH+xED2Dr1NJ/SGWp85SC/r/bloiLRtj8K59rJrYhcfsNQMoMhpLlvhKqrN0HIGb9/V9BcYKxWV8HT/jjDdvfHLUVfo688I6WpapcX41GzL4VnGGDdFyUom53odJncjHszS3dpTrG1OKp2x9dtdG+924/+Ne49KN4nztd1pikqYeqQuxflKCmh+VcCFbDclQ8a9NUpqWkPpeoweISVVuZDnP9WFoKG5X6YcpXBWH6wjc69xK4cs6KkJ
The conversation key is nip44_conversation_key(private_key, public_key) — ECDH of the key with itself. NIP-44 v2 uses a random nonce, so re-encryption will produce different ciphertext. Verification is decrypt-only: any conforming NIP-44 implementation MUST satisfy decrypt(private_key, public_key, ciphertext) == plaintext.
Conflict Detection Vector
Device A has slot-id = aaa111 and client_id = client-A. It fetches its own d tag coordinate read-state:aaa111 and decrypts the blob. The decrypted client_id is client-B (not client-A). This is a slot-id conflict — another device has claimed this coordinate.
Device A MUST NOT publish to read-state:aaa111. Device A MUST generate a new random slot-id (e.g., ccc333) and publish its blob under read-state:ccc333.
Clock Skew Vector
Device A fetches its own blob from two relays:
- Relay 1 returns the blob with
created_at= 1700001000 - Relay 2 returns the blob with
created_at= 1700001500
Device A's local clock reads 1700001200 (behind Relay 2). The maximum fetched created_at is 1700001500.
Device A MUST publish with created_at = 1700001501 (max_fetched + 1), not 1700001200.
Invalid Cases
Clients MUST reject or discard each of the following:
- A blob whose
contentdoes not decrypt to valid JSON — discard the entire event. - A blob with a missing
client_idfield — discard the entire event. - A blob with
v: 2(unknown version) — ignore the entire event. - A blob with a non-integer timestamp for a context entry (e.g.,
"ctx:AAA": "yesterday") — discard that context entry; process remaining entries. - A blob with a context ID exceeding 256 bytes — discard that context entry; process remaining entries.
- A blob with more than 10,000 context entries — client MUST reject the entire blob.
- An event with no
dtag — ignore the entire event. - An event with a
dtag value that does not begin withread-state:— ignore the entire event.
Privacy Considerations
The content field is NIP-44 encrypted to the user's own keypair. Context identifiers, timestamps, and the client_id are not visible to relay operators or other users. As with all NIP-44 encrypt-to-self data, compromise of the user's private key exposes all stored read state.
The d tag prefix read-state: and the number of distinct slot IDs are visible to relay operators, revealing that the user employs read-state sync and approximately how many client instances they run. Write frequency may reveal approximate activity level.
Ciphertext length reveals the approximate number of tracked contexts and may correlate with the user's activity level across sessions.
Because slot IDs are random and independent of client_id values, relay operators cannot directly link blobs to specific devices or client implementations. Timing correlation and write patterns may still allow probabilistic linkage.
Because the merge rule is monotonic, replaying an old event to a relay is harmless — it cannot lower a read timestamp. However, replaying many old events simultaneously could trigger convergence re-publishes from active clients. The debounce window (see Debounce and Pruning) limits this to at most one re-publish per window.
Clients supporting multiple Nostr identities SHOULD use distinct client_id values and distinct slot IDs per identity. Reusing identifiers across pubkeys allows relay operators to link those identities.
Clients SHOULD describe relay-managed read state wherever they describe relay-synced account data. This NIP does not authorize read receipts; clients that expose read activity to other users MUST require explicit user consent.
Kind Usage
| Kind | Usage |
|---|---|
30078 |
Per-client read state blob (parameterized replaceable, NIP-78) |
Backwards Compatibility
This NIP introduces no changes to existing event kinds or relay behavior. It uses only standard NIP-01 event storage, NIP-33 addressable event semantics, NIP-44 encryption, and NIP-78 application data conventions. Clients that do not implement this NIP are unaffected.