Files
buzz/docs/nips/NIP-RS.md
T

28 KiB
Raw Permalink Blame History

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 164 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>
  }
}
  • v is an integer schema version. Clients MUST ignore blobs with unknown v values.
  • client_id is a non-empty UTF-8 string of 164 characters identifying this client instance. It is generated on first launch and persisted locally. Each client instance MUST use a stable, unique client_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 contexts are 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 content does not decrypt to valid JSON MUST be discarded.
  • Events with a missing or non-integer v field MUST be discarded.
  • Events with an unknown v value MUST be ignored.
  • Events with a missing client_id field MUST be discarded.
  • Events with a client_id that is not a non-empty string of 164 UTF-8 characters MUST be discarded.
  • Events with a missing contexts field MUST be discarded.
  • Events whose contexts field is not a JSON object MUST be discarded.
  • Individual context entries whose timestamp is not an integer in the range 04294967295 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 23) 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:

  1. Decrypt each blob.
  2. Discard blobs that fail validation (see Content Validation).
  3. Identify the blob whose decrypted client_id matches the client's own client_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.

  1. 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:

  1. 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>"]}
  1. Decrypt and merge the fetched blob with local state using max() per context.
  2. 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):

  1. Merge it into local state using max() per context.
  2. 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.
  3. 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.
  4. 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 510 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 content does not decrypt to valid JSON — discard the entire event.
  • A blob with a missing client_id field — 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 d tag — ignore the entire event.
  • An event with a d tag value that does not begin with read-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.

References

  • NIP-09 — Event Deletion Request
  • NIP-33 — Parameterized Replaceable Events
  • NIP-44 — Versioned Encryption
  • NIP-78 — Arbitrary Custom App Data (defines kind:30078 for application-specific data)