Add Nostr Protocol Specification
591
Nostr-Protocol-Specification.md
Normal file
591
Nostr-Protocol-Specification.md
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
## 1. Event Structure
|
||||||
|
|
||||||
|
An event is the fundamental data unit. Every event contains exactly seven fields.
|
||||||
|
|
||||||
|
```
|
||||||
|
Event {
|
||||||
|
id: 64-character lowercase hexadecimal string
|
||||||
|
pubkey: 64-character lowercase hexadecimal string
|
||||||
|
created_at: integer (Unix timestamp in seconds)
|
||||||
|
kind: integer
|
||||||
|
tags: array of arrays of strings
|
||||||
|
content: string
|
||||||
|
sig: 128-character lowercase hexadecimal signature
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65",
|
||||||
|
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
|
||||||
|
"created_at": 1673347337,
|
||||||
|
"kind": 1,
|
||||||
|
"tags": [["p", "91cf9..4e5ca"], ["e", "ae3f2..7b29d"]],
|
||||||
|
"content": "Hello, Nostr!",
|
||||||
|
"sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd0092619a2c8c1221e581946e0191f2af505dfdf8657a414dbca329186f009262"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 1.1: Field Requirements
|
||||||
|
|
||||||
|
All seven fields must be present. No field may be null or omitted.
|
||||||
|
|
||||||
|
### Rule 1.2: Hexadecimal Encoding
|
||||||
|
|
||||||
|
The `id`, `pubkey`, and `sig` fields use lowercase hexadecimal encoding without prefix.
|
||||||
|
|
||||||
|
- `id`: 32 bytes = 64 hex characters
|
||||||
|
- `pubkey`: 32 bytes = 64 hex characters
|
||||||
|
- `sig`: 64 bytes = 128 hex characters
|
||||||
|
|
||||||
|
## 2. Event ID Calculation
|
||||||
|
|
||||||
|
The event ID is a SHA-256 hash of a serialized form.
|
||||||
|
|
||||||
|
### Rule 2.1: Serialization Format
|
||||||
|
|
||||||
|
Serialize as a JSON array with six elements in this exact order:
|
||||||
|
|
||||||
|
```
|
||||||
|
[0, pubkey, created_at, kind, tags, content]
|
||||||
|
```
|
||||||
|
|
||||||
|
The zero is a protocol version indicator.
|
||||||
|
|
||||||
|
**Example serialization:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[0,"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",1673347337,1,[["p","91cf9..4e5ca"]],"Hello"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 2.2: Canonical JSON
|
||||||
|
|
||||||
|
The serialization must use canonical JSON:
|
||||||
|
|
||||||
|
- No whitespace between elements
|
||||||
|
- No whitespace after separators
|
||||||
|
- UTF-8 encoding
|
||||||
|
- Minimum representation (no trailing zeros on numbers)
|
||||||
|
|
||||||
|
### Rule 2.3: Hash Computation
|
||||||
|
|
||||||
|
```
|
||||||
|
serialized_bytes = UTF8_encode(canonical_json)
|
||||||
|
hash_bytes = SHA256(serialized_bytes)
|
||||||
|
event.id = lowercase_hex_encode(hash_bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Input: [0,"6e46...",1673347337,1,[],"Hello"]
|
||||||
|
SHA-256: 4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Event Signature
|
||||||
|
|
||||||
|
The signature proves the event was created by the holder of the private key corresponding to `pubkey`.
|
||||||
|
|
||||||
|
### Rule 3.1: Signature Generation
|
||||||
|
|
||||||
|
```
|
||||||
|
signature_bytes = schnorr_sign(private_key, event_id_bytes)
|
||||||
|
event.sig = lowercase_hex_encode(signature_bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses Schnorr signature scheme over the secp256k1 elliptic curve.
|
||||||
|
|
||||||
|
### Rule 3.2: Signature Verification
|
||||||
|
|
||||||
|
```
|
||||||
|
is_valid = schnorr_verify(
|
||||||
|
public_key: event.pubkey_bytes,
|
||||||
|
message: event.id_bytes,
|
||||||
|
signature: event.sig_bytes
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns true if signature is valid, false otherwise.
|
||||||
|
|
||||||
|
### Rule 3.3: Key Derivation
|
||||||
|
|
||||||
|
The public key is derived from the private key using secp256k1 scalar multiplication:
|
||||||
|
|
||||||
|
```
|
||||||
|
public_key_point = private_key * G
|
||||||
|
event.pubkey = lowercase_hex_encode(public_key_point.x_coordinate)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where G is the secp256k1 generator point.
|
||||||
|
|
||||||
|
## 4. Tag Structure
|
||||||
|
|
||||||
|
Tags are arrays of strings. The first element identifies the tag type.
|
||||||
|
|
||||||
|
### Rule 4.1: Minimum Tag Length
|
||||||
|
|
||||||
|
Every tag must contain at least two elements: a tag name and a value.
|
||||||
|
|
||||||
|
```
|
||||||
|
valid_tag = ["e", "ae3f2..7b29d"]
|
||||||
|
invalid_tag = ["e"] // missing value
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 4.2: Tag Array Format
|
||||||
|
|
||||||
|
```
|
||||||
|
Event.tags = [
|
||||||
|
[tag_name, value, optional_1, optional_2, ...],
|
||||||
|
[tag_name, value, optional_1, optional_2, ...],
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"tags": [
|
||||||
|
["e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://relay.example.com"],
|
||||||
|
["p", "91cf9..4e5ca", "", "alice"],
|
||||||
|
["d", "my-article-slug"]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 4.3: Common Tag Types
|
||||||
|
|
||||||
|
Standard single-letter tag names:
|
||||||
|
|
||||||
|
- `e`: Event ID reference
|
||||||
|
- `p`: Public key reference
|
||||||
|
- `a`: Address reference (for replaceable events)
|
||||||
|
- `d`: Identifier (for parameterized replaceable events)
|
||||||
|
- `t`: Topic/hashtag
|
||||||
|
|
||||||
|
Additional elements in the array provide context (relay hints, markers, etc.).
|
||||||
|
|
||||||
|
## 5. WebSocket Message Protocol
|
||||||
|
|
||||||
|
All communication uses JSON arrays over WebSocket connections.
|
||||||
|
|
||||||
|
### Rule 5.1: Client to Relay Messages
|
||||||
|
|
||||||
|
**EVENT** - Publish an event:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["EVENT", <event-object>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**REQ** - Subscribe to events:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["REQ", <subscription-id>, <filter-object>, <filter-object>, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLOSE** - End subscription:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["CLOSE", <subscription-id>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**AUTH** - Authenticate with relay:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["AUTH", <signed-event>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
["REQ", "my-sub-1", {"kinds": [1], "limit": 10}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 5.2: Relay to Client Messages
|
||||||
|
|
||||||
|
**EVENT** - Deliver matching event:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["EVENT", <subscription-id>, <event-object>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**EOSE** - End of stored events:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["EOSE", <subscription-id>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**OK** - Confirm event acceptance:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["OK", <event-id>, <true|false>, <message>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLOSED** - Subscription ended:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["CLOSED", <subscription-id>, <message>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTICE** - Human-readable message:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["NOTICE", <message>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**AUTH** - Request authentication:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["AUTH", <challenge-string>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
["OK", "4376c65d...", true, "Event accepted"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 5.3: Message Ordering
|
||||||
|
|
||||||
|
Relays send stored events first, then EOSE, then real-time events for active subscriptions.
|
||||||
|
|
||||||
|
```
|
||||||
|
CLIENT: ["REQ", "sub1", {...filter...}]
|
||||||
|
RELAY: ["EVENT", "sub1", {...stored event 1...}]
|
||||||
|
RELAY: ["EVENT", "sub1", {...stored event 2...}]
|
||||||
|
RELAY: ["EOSE", "sub1"]
|
||||||
|
RELAY: ["EVENT", "sub1", {...new real-time event...}]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Filter Structure
|
||||||
|
|
||||||
|
Filters define subscription criteria. All conditions within a filter are combined with AND logic. Multiple filters in one REQ are combined with OR logic.
|
||||||
|
|
||||||
|
### Rule 6.1: Filter Fields
|
||||||
|
|
||||||
|
```
|
||||||
|
Filter {
|
||||||
|
ids: [string, ...] // event ID prefixes
|
||||||
|
authors: [string, ...] // pubkey prefixes
|
||||||
|
kinds: [integer, ...] // event kinds
|
||||||
|
since: integer // minimum timestamp
|
||||||
|
until: integer // maximum timestamp
|
||||||
|
limit: integer // maximum results
|
||||||
|
#<letter>: [string, ...] // tag filters
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All fields are optional. An empty filter matches all events.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ids": ["4376c65d"],
|
||||||
|
"authors": ["6e468422", "91cf9b32"],
|
||||||
|
"kinds": [1, 6, 7],
|
||||||
|
"since": 1673347337,
|
||||||
|
"until": 1673433737,
|
||||||
|
"limit": 100,
|
||||||
|
"#e": ["5c83da77", "ae3f2a91"],
|
||||||
|
"#p": ["91cf9b32"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 6.2: Prefix Matching
|
||||||
|
|
||||||
|
The `ids` and `authors` fields support prefix matching. A filter value matches if it equals the initial substring of the event field.
|
||||||
|
|
||||||
|
```
|
||||||
|
filter.ids = ["4376c6"]
|
||||||
|
matches: "4376c65d2f232afbe9b882a35baa4f6fe8667c4e..."
|
||||||
|
rejects: "5c83da77..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimum recommended prefix length: 4 characters (8 hex digits).
|
||||||
|
|
||||||
|
### Rule 6.3: Tag Filters
|
||||||
|
|
||||||
|
Tag filters use the format `#<single-letter>` as the field name.
|
||||||
|
|
||||||
|
```
|
||||||
|
filter["#e"] = ["5c83da77...", "ae3f2a91..."]
|
||||||
|
```
|
||||||
|
|
||||||
|
Matches events containing tags where:
|
||||||
|
|
||||||
|
- First element (tag name) equals the letter
|
||||||
|
- Second element (tag value) matches any value in the filter array
|
||||||
|
|
||||||
|
**Example matching:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Event tags: [["e", "5c83da77..."], ["p", "91cf9..."]]
|
||||||
|
Filter: {"#e": ["5c83da77..."]}
|
||||||
|
Result: MATCH (event has "e" tag with matching value)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 6.4: Time Range Filters
|
||||||
|
|
||||||
|
- `since`: Event timestamp must be ≥ this value
|
||||||
|
- `until`: Event timestamp must be ≤ this value
|
||||||
|
|
||||||
|
Both use Unix timestamps (seconds since 1970-01-01 00:00:00 UTC).
|
||||||
|
|
||||||
|
### Rule 6.5: Limit
|
||||||
|
|
||||||
|
The `limit` field caps the number of events returned. Relays should return the most recent events when applying limits.
|
||||||
|
|
||||||
|
## 7. Event Validation
|
||||||
|
|
||||||
|
### Rule 7.1: Structure Validation
|
||||||
|
|
||||||
|
Check that:
|
||||||
|
|
||||||
|
1. All seven required fields exist
|
||||||
|
2. Field types match specification
|
||||||
|
3. Hex fields have correct length
|
||||||
|
4. Tags is an array of arrays
|
||||||
|
5. Each tag has at least two elements
|
||||||
|
6. Timestamp is an integer
|
||||||
|
|
||||||
|
### Rule 7.2: ID Validation
|
||||||
|
|
||||||
|
Recompute the event ID and verify it matches the stored `id` field:
|
||||||
|
|
||||||
|
```
|
||||||
|
computed_id = sha256_hex(serialize([0, pubkey, created_at, kind, tags, content]))
|
||||||
|
is_valid = (computed_id == event.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 7.3: Signature Validation
|
||||||
|
|
||||||
|
Verify the Schnorr signature:
|
||||||
|
|
||||||
|
```
|
||||||
|
is_valid = schnorr_verify(
|
||||||
|
pubkey_bytes,
|
||||||
|
id_bytes,
|
||||||
|
sig_bytes
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
An event must pass both ID validation and signature validation to be considered authentic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional Features
|
||||||
|
|
||||||
|
The following features are implemented by some clients and relays but are not required for basic protocol operation.
|
||||||
|
|
||||||
|
## Optional: Event Kind Classification
|
||||||
|
|
||||||
|
Some implementations categorize event kinds by behavior.
|
||||||
|
|
||||||
|
### Optional Rule 8.1: Kind Ranges
|
||||||
|
|
||||||
|
**Regular events** (default): Stored permanently with unique IDs.
|
||||||
|
|
||||||
|
**Replaceable events** (10000-19999): Newest event per (kind, pubkey) replaces all previous.
|
||||||
|
|
||||||
|
- Also: kinds 0, 3 are treated as replaceable
|
||||||
|
|
||||||
|
**Ephemeral events** (20000-29999): Not stored by relays, only forwarded.
|
||||||
|
|
||||||
|
**Parameterized replaceable events** (30000-39999): Newest event per (kind, pubkey, d-tag-value) replaces previous.
|
||||||
|
|
||||||
|
### Optional Rule 8.2: Replaceable Event Behavior
|
||||||
|
|
||||||
|
When a relay receives a replaceable event:
|
||||||
|
|
||||||
|
1. Find existing events with same (kind, pubkey)
|
||||||
|
2. Compare timestamps
|
||||||
|
3. Keep only the newest event
|
||||||
|
4. Delete older events
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Existing: kind=0, pubkey="abc123", created_at=1000
|
||||||
|
New: kind=0, pubkey="abc123", created_at=1500
|
||||||
|
Action: Delete existing, store new
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Rule 8.3: Parameterized Replaceable Events
|
||||||
|
|
||||||
|
Uses a `d` tag to create unique identifiers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30023,
|
||||||
|
"pubkey": "abc123...",
|
||||||
|
"tags": [["d", "my-article"]],
|
||||||
|
"content": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replacement key: `(kind=30023, pubkey="abc123...", d="my-article")`
|
||||||
|
|
||||||
|
## Optional: Address Format
|
||||||
|
|
||||||
|
Parameterized replaceable events can be referenced by address instead of ID.
|
||||||
|
|
||||||
|
### Optional Rule 9.1: Address Construction
|
||||||
|
|
||||||
|
```
|
||||||
|
address = "<kind>:<pubkey>:<d-tag-value>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
"30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:my-article"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Rule 9.2: Address Tag
|
||||||
|
|
||||||
|
Reference addresses in tags:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["a", "30023:6e4684...:my-article", "wss://relay.example.com"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional: Proof of Work
|
||||||
|
|
||||||
|
Events can include proof-of-work by mining a nonce.
|
||||||
|
|
||||||
|
### Optional Rule 10.1: Difficulty Measurement
|
||||||
|
|
||||||
|
Count leading zero bits in the event ID:
|
||||||
|
|
||||||
|
```
|
||||||
|
difficulty = count_leading_zero_bits(event.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
event.id = "000000af4b3c2..." (24 leading zero bits = difficulty 24)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Rule 10.2: Nonce Tag
|
||||||
|
|
||||||
|
Include a nonce tag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["nonce", "12345678", "24"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
|
||||||
|
- First value: nonce counter
|
||||||
|
- Second value: target difficulty
|
||||||
|
- Third value (optional): commitment
|
||||||
|
|
||||||
|
### Optional Rule 10.3: Mining Process
|
||||||
|
|
||||||
|
```
|
||||||
|
target_difficulty = 24
|
||||||
|
nonce = 0
|
||||||
|
loop:
|
||||||
|
event.tags = [["nonce", string(nonce), string(target_difficulty)]]
|
||||||
|
event.id = compute_event_id(event)
|
||||||
|
if count_leading_zero_bits(event.id) >= target_difficulty:
|
||||||
|
break
|
||||||
|
nonce = nonce + 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional: Full-Text Search
|
||||||
|
|
||||||
|
Filters may include a search field.
|
||||||
|
|
||||||
|
### Optional Rule 11.1: Search Filter
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kinds": [1],
|
||||||
|
"search": "bitcoin protocol"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Matches events where the `content` field contains the search terms. Implementation-specific (case sensitivity, word boundaries, etc.).
|
||||||
|
|
||||||
|
## Optional: Event Counting
|
||||||
|
|
||||||
|
Request event counts without retrieving full events.
|
||||||
|
|
||||||
|
### Optional Rule 12.1: COUNT Command
|
||||||
|
|
||||||
|
Client sends:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["COUNT", <subscription-id>, <filter-object>, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Relay responds:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["COUNT", <subscription-id>, {"count": 47}]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional: Event Relationships
|
||||||
|
|
||||||
|
Track parent-child relationships between events.
|
||||||
|
|
||||||
|
### Optional Rule 13.1: Reply Markers
|
||||||
|
|
||||||
|
The `e` tag can include a marker:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["e", "<parent-event-id>", "<relay-hint>", "reply"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Markers: `reply`, `root`, `mention`
|
||||||
|
|
||||||
|
### Optional Rule 13.2: Parent Extraction
|
||||||
|
|
||||||
|
```
|
||||||
|
get_parent_ids(event):
|
||||||
|
return [tag[1] for tag in event.tags where tag[0] == "e"]
|
||||||
|
|
||||||
|
get_parent_addresses(event):
|
||||||
|
return [tag[1] for tag in event.tags where tag[0] == "a"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional: Event Expiration
|
||||||
|
|
||||||
|
Events can specify an expiration time.
|
||||||
|
|
||||||
|
### Optional Rule 14.1: Expiration Tag
|
||||||
|
|
||||||
|
```json
|
||||||
|
["expiration", "1673433737"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Value is a Unix timestamp. Relays should delete the event after this time.
|
||||||
|
|
||||||
|
### Optional Rule 14.2: Expiration Check
|
||||||
|
|
||||||
|
```
|
||||||
|
is_expired(event, current_time):
|
||||||
|
for tag in event.tags:
|
||||||
|
if tag[0] == "expiration":
|
||||||
|
return current_time > parse_int(tag[1])
|
||||||
|
return false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional: Event Sorting
|
||||||
|
|
||||||
|
Canonical ordering for event lists.
|
||||||
|
|
||||||
|
### Optional Rule 15.1: Sort Order
|
||||||
|
|
||||||
|
Primary sort: `created_at` descending (newest first)
|
||||||
|
Tiebreaker: `id` ascending (lexicographic)
|
||||||
|
|
||||||
|
```
|
||||||
|
sort_events(events):
|
||||||
|
return sorted(events, key=lambda e: (-e.created_at, e.id))
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives a deterministic ordering when multiple events have the same timestamp.
|
||||||
Reference in New Issue
Block a user