NIP-CF: Changes Feed

This NIP defines an optional relay extension for sequence-based event synchronization.
NIP-CF: Changes Feed

# NIP-CF: Changes Feed

draft optional

This NIP defines an optional relay extension for sequence-based event synchronization.

## Abstract

Standard Nostr sync uses timestamp-based filters (since), which can miss events due to timestamp collisions or clock drift. This NIP defines a changes feed that uses monotonically increasing sequence numbers for precise, reliable synchronization.

## Motivation

Timestamp-based sync has limitations:

- Collisions: Multiple events can share the same second-precision timestamp

- Clock drift: Client and relay clocks may differ

- Imprecise checkpointing: "Give me events since timestamp X" is fuzzy

Sequence numbers solve these issues by providing a strict total ordering of all events stored by a relay.

## Relay Requirements

Relays implementing this NIP:

MUST:

- Assign a monotonically increasing sequence number to each stored event

- Support the CHANGES message type

- Support continuous/live changes feeds

## Capability Advertisement

Relays MUST advertise support by including this NIP in their NIP-11 supported_nips array.

Clients SHOULD check this before using the changes feed and fall back to timestamp-based sync if not supported.

## Protocol

### CHANGES Request (client to relay)

```json

["CHANGES", <subscription_id>, <filter>]

```

The filter object supports:

| Field | Type | Description |

| --- | --- | --- |

| since | integer | Return events with seq > since (default: 0) |

| limit | integer | Maximum number of events to return (optional) |

| kinds | int[] | Filter by event kinds (optional) |

| authors | string[] | Filter by author pubkeys (optional) |

| #<tag> | string[] | Filter by tag values, same as NIP-01 (optional) |

| live | boolean | Keep subscription open for real-time updates (optional) |

Example:

```json

["CHANGES", "sync-1", {

"since": 0,

"kinds": [1, 30023],

"authors": ["<pubkey>"]

}]

```

### CHANGES EVENT (relay to client)

For each matching event, the relay sends:

```json

["CHANGES", <subscription_id>, "EVENT", <seq>, <event>]

```

- seq: The sequence number assigned to this event (integer)

- event: The full Nostr event object

Events MUST be sent in sequence order (ascending).

### CHANGES EOSE (relay to client)

After sending all stored events matching the filter:

```json

["CHANGES", <subscriptionid>, "EOSE", <lastseq>]

```

- last_seq: The relay's current maximum sequence number

The last_seq value is always the global maximum, even if no events matched the filter. This allows clients to advance their checkpoint without re-querying the same range.

### CHANGES ERR (relay to client)

If the request cannot be processed:

```json

["CHANGES", <subscription_id>, "ERR", <message>]

```

After an error, the subscription is closed.

### Closing a Subscription

Clients close a changes subscription with a standard CLOSE message:

```json

["CLOSE", <subscription_id>]

```

## Live/Continuous Mode

If the client includes "live": true in the filter, the relay keeps the subscription open after EOSE and sends new matching events in real-time:

```javascript

Client: ["CHANGES", "s1", {"since": 42, "kinds": [1], "live": true}]

Relay: ["CHANGES", "s1", "EVENT", 43, {...}]

Relay: ["CHANGES", "s1", "EVENT", 50, {...}]

Relay: ["CHANGES", "s1", "EOSE", 50]

-- subscription stays open --

Relay: ["CHANGES", "s1", "EVENT", 51, {...}] (new event arrives)

Relay: ["CHANGES", "s1", "EVENT", 52, {...}] (another new event)

Client: ["CLOSE", "s1"]

```

## Client Implementation

### Sync Flow

Initial sync:

```javascript

// Check if relay supports changes feed (NIP-CF)

const info = await fetch(relayUrl.replace('wss', 'https'))

const nip11 = await info.json()

if (nip11.supported_nips?.includes('CF')) {

// Use changes feed

send(["CHANGES", "sync", { since: 0, kinds: [1234], authors: [pubkey] }])

} else {

// Fall back to timestamp-based

send(["REQ", "sync", { kinds: [1234], authors: [pubkey] }])

}

```

Processing responses:

```javascript

let checkpoint = loadCheckpoint() || 0

relay.on('message', (msg) => {

if (msg[0] === 'CHANGES' && msg[1] === 'sync') {

if (msg[2] === 'EVENT') {

const seq = msg[3]

const event = msg[4]

processEvent(event)

} else if (msg[2] === 'EOSE') {

const lastSeq = msg[3]

checkpoint = lastSeq

saveCheckpoint(checkpoint)

}

}

})

```

Incremental sync:

```javascript

send(["CHANGES", "sync", { since: checkpoint, kinds: [1234], authors: [pubkey] }])

```

### Checkpoint Storage

Clients SHOULD persist their checkpoint (last seen sequence number) to enable efficient incremental sync across sessions.

Note: Sequence numbers are relay-specific. Clients syncing from multiple relays need separate checkpoints for each.

## Security Considerations

- Sequence numbers may reveal information about relay activity (event frequency)

- Relays SHOULD rate-limit changes feed requests like other subscriptions

- The limit parameter helps prevent excessive resource usage


No comments yet.