Files
buzz/NIP-RS.md

20 KiB
Raw Permalink Blame History

NIP-RS

Cross-Device Read State Sync

draft optional

Abstract

This NIP defines a scheme for synchronizing per-context read state (e.g., "read up to timestamp T") across multiple client instances belonging to the same user, using encrypted kind:30078 events.

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 to this NIP and meaningful only within a single client family. 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.

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.

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 MUST NOT publish read state without explicit user opt-in. Opt-in is a persistent user preference, not enabled by default.

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.

This feature is opt-in. Clients MUST NOT publish read state events unless the user has explicitly enabled the feature.

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)