diff options
Diffstat (limited to 'nostr-grpc-relay-design.md')
| -rw-r--r-- | nostr-grpc-relay-design.md | 901 |
1 files changed, 901 insertions, 0 deletions
diff --git a/nostr-grpc-relay-design.md b/nostr-grpc-relay-design.md new file mode 100644 index 0000000..29e90c4 --- /dev/null +++ b/nostr-grpc-relay-design.md | |||
| @@ -0,0 +1,901 @@ | |||
| 1 | # Nostr gRPC Relay - Design Document | ||
| 2 | |||
| 3 | ## Project Overview | ||
| 4 | |||
| 5 | A high-performance Nostr relay implementation that speaks both the standard WebSocket protocol (for compatibility) and gRPC (for performance and developer experience). The relay is **gRPC-first**, storing events in binary Protocol Buffer format, with WebSocket/JSON as a compatibility layer. | ||
| 6 | |||
| 7 | ## Core Philosophy | ||
| 8 | |||
| 9 | - **Transport as Implementation Detail**: Business logic is protocol-agnostic | ||
| 10 | - **Binary-First Storage**: Events stored as protobuf, JSON generated on-demand for WebSocket clients | ||
| 11 | - **Verify Once, Trust Forever**: Signature verification happens at ingestion only (events are immutable) | ||
| 12 | - **Dual Protocol**: Full Nostr compatibility via WebSocket + extended features via gRPC | ||
| 13 | |||
| 14 | ## Architecture | ||
| 15 | |||
| 16 | ``` | ||
| 17 | ┌─────────────────────────────────────────────────────────┐ | ||
| 18 | │ Transport Layer │ | ||
| 19 | │ ┌──────────────────┐ ┌──────────────────┐ │ | ||
| 20 | │ │ WebSocket │ │ gRPC │ │ | ||
| 21 | │ │ (Nostr JSON) │ │ (Protobuf) │ │ | ||
| 22 | │ │ - NIP-01 │ │ - Native API │ │ | ||
| 23 | │ │ - NIP-09 │ │ - Extensions │ │ | ||
| 24 | │ │ - NIP-11 │ │ - Admin API │ │ | ||
| 25 | │ │ - NIP-42 │ │ │ │ | ||
| 26 | │ └────────┬─────────┘ └────────┬─────────┘ │ | ||
| 27 | │ │ │ │ | ||
| 28 | │ └──────────┬───────────────┘ │ | ||
| 29 | └──────────────────────┼──────────────────────────────────┘ | ||
| 30 | │ | ||
| 31 | ┌──────────────────────▼──────────────────────────────────┐ | ||
| 32 | │ Application Layer │ | ||
| 33 | │ ┌─────────────────────────────────────────────────┐ │ | ||
| 34 | │ │ Subscription Manager │ │ | ||
| 35 | │ │ - Filter matching (shared across protocols) │ │ | ||
| 36 | │ │ - Active subscription tracking │ │ | ||
| 37 | │ │ - Event fan-out to subscribers │ │ | ||
| 38 | │ │ - Deduplication │ │ | ||
| 39 | │ └─────────────────────────────────────────────────┘ │ | ||
| 40 | │ ┌─────────────────────────────────────────────────┐ │ | ||
| 41 | │ │ Event Processor │ │ | ||
| 42 | │ │ - Signature validation (schnorr) │ │ | ||
| 43 | │ │ - Event ID verification (SHA256) │ │ | ||
| 44 | │ │ - Canonical JSON reconstruction │ │ | ||
| 45 | │ │ - Rate limiting / spam filtering │ │ | ||
| 46 | │ └─────────────────────────────────────────────────┘ │ | ||
| 47 | │ ┌─────────────────────────────────────────────────┐ │ | ||
| 48 | │ │ Auth Manager │ │ | ||
| 49 | │ │ - NIP-42 (WebSocket auth) │ │ | ||
| 50 | │ │ - gRPC auth (mTLS, JWT, API keys) │ │ | ||
| 51 | │ └─────────────────────────────────────────────────┘ │ | ||
| 52 | └──────────────────────┬──────────────────────────────────┘ | ||
| 53 | │ | ||
| 54 | ┌──────────────────────▼──────────────────────────────────┐ | ||
| 55 | │ Storage Layer (SQLite) │ | ||
| 56 | │ ┌─────────────────────────────────────────────────┐ │ | ||
| 57 | │ │ Event Store │ │ | ||
| 58 | │ │ - Binary protobuf storage (primary) │ │ | ||
| 59 | │ │ - Compressed canonical JSON (for verification) │ │ | ||
| 60 | │ │ - Denormalized indexes (pubkey, kind, etc) │ │ | ||
| 61 | │ └─────────────────────────────────────────────────┘ │ | ||
| 62 | └─────────────────────────────────────────────────────────┘ | ||
| 63 | ``` | ||
| 64 | |||
| 65 | ## Key Design Decisions | ||
| 66 | |||
| 67 | ### 1. Binary-First Storage | ||
| 68 | |||
| 69 | **Why**: | ||
| 70 | - 40% smaller storage footprint | ||
| 71 | - Zero serialization cost for gRPC clients | ||
| 72 | - Faster queries on indexed fields | ||
| 73 | |||
| 74 | **How**: | ||
| 75 | - Store events as protobuf blobs | ||
| 76 | - Maintain denormalized fields (pubkey, kind, created_at, tags) for indexing | ||
| 77 | - Store compressed canonical JSON alongside for signature verification | ||
| 78 | |||
| 79 | ### 2. Verify Signatures Once | ||
| 80 | |||
| 81 | **Rationale**: | ||
| 82 | - Nostr events are immutable (content-addressed by hash) | ||
| 83 | - Once signature is verified at ingestion, it never needs re-checking | ||
| 84 | - Clients already trust relays for many things (filtering, availability) | ||
| 85 | |||
| 86 | **Implementation**: | ||
| 87 | - Verify signature during event ingestion | ||
| 88 | - Reject invalid events immediately | ||
| 89 | - Store only valid events with `signature_valid=true` metadata | ||
| 90 | - Clients can opt-in to receive canonical JSON for their own verification | ||
| 91 | |||
| 92 | ### 3. Dual Storage for Correctness | ||
| 93 | |||
| 94 | Store both protobuf AND canonical JSON: | ||
| 95 | - **Protobuf** (`event_data`): Fast queries, native gRPC serving | ||
| 96 | - **Canonical JSON** (`canonical_json`): Exact bytes that were signed, compressed with zstd | ||
| 97 | |||
| 98 | **Storage overhead**: ~150 bytes per event (compressed JSON) | ||
| 99 | - 1M events = 150MB extra | ||
| 100 | - Worth it for correctness and debuggability | ||
| 101 | |||
| 102 | ### 4. SQLite for Storage | ||
| 103 | |||
| 104 | **Why SQLite**: | ||
| 105 | - Complex queries (Nostr filters) map directly to SQL | ||
| 106 | - Proven in production Nostr relays (strfry, nostream) | ||
| 107 | - Built-in indexing and query optimization | ||
| 108 | - JSON support for tag queries | ||
| 109 | - WAL mode: 10k-50k reads/sec, 1k-5k writes/sec | ||
| 110 | - Pure Go driver available (`modernc.org/sqlite`) | ||
| 111 | |||
| 112 | **Why not BadgerDB/Pebble**: | ||
| 113 | - Would require building custom query engine | ||
| 114 | - Complex index management | ||
| 115 | - More implementation work for same result | ||
| 116 | |||
| 117 | ## Protocol Buffers Schema | ||
| 118 | |||
| 119 | ```protobuf | ||
| 120 | syntax = "proto3"; | ||
| 121 | package nostr; | ||
| 122 | |||
| 123 | // Core Nostr event | ||
| 124 | message Event { | ||
| 125 | string id = 1; | ||
| 126 | string pubkey = 2; | ||
| 127 | int64 created_at = 3; | ||
| 128 | int32 kind = 4; | ||
| 129 | repeated Tag tags = 5; | ||
| 130 | string content = 6; | ||
| 131 | string sig = 7; | ||
| 132 | |||
| 133 | // Optional: only populated if client requests verification | ||
| 134 | // Contains exact canonical JSON bytes that were signed | ||
| 135 | optional bytes canonical_json = 8; | ||
| 136 | } | ||
| 137 | |||
| 138 | message Tag { | ||
| 139 | repeated string values = 1; // e.g., ["e", "event_id", "relay_url"] | ||
| 140 | } | ||
| 141 | |||
| 142 | // Nostr filter (REQ) | ||
| 143 | message Filter { | ||
| 144 | repeated string ids = 1; | ||
| 145 | repeated string authors = 2; // pubkeys | ||
| 146 | repeated int32 kinds = 3; | ||
| 147 | repeated string e_tags = 4; // #e tag values | ||
| 148 | repeated string p_tags = 5; // #p tag values | ||
| 149 | int64 since = 6; | ||
| 150 | int64 until = 7; | ||
| 151 | int32 limit = 8; | ||
| 152 | |||
| 153 | // Extension: support for arbitrary tag filters | ||
| 154 | map<string, TagFilter> tag_filters = 9; | ||
| 155 | } | ||
| 156 | |||
| 157 | message TagFilter { | ||
| 158 | repeated string values = 1; | ||
| 159 | } | ||
| 160 | |||
| 161 | // gRPC Services | ||
| 162 | service NostrRelay { | ||
| 163 | // Publish a single event | ||
| 164 | rpc PublishEvent(PublishEventRequest) returns (PublishEventResponse); | ||
| 165 | |||
| 166 | // Subscribe to events matching filters (streaming) | ||
| 167 | rpc Subscribe(SubscribeRequest) returns (stream Event); | ||
| 168 | |||
| 169 | // Unsubscribe from an active subscription | ||
| 170 | rpc Unsubscribe(UnsubscribeRequest) returns (Empty); | ||
| 171 | |||
| 172 | // gRPC-specific: batch publish | ||
| 173 | rpc PublishBatch(PublishBatchRequest) returns (PublishBatchResponse); | ||
| 174 | |||
| 175 | // gRPC-specific: paginated query (non-streaming) | ||
| 176 | rpc QueryEvents(QueryRequest) returns (QueryResponse); | ||
| 177 | |||
| 178 | // Event counts (NIP-45) | ||
| 179 | rpc CountEvents(CountRequest) returns (CountResponse); | ||
| 180 | } | ||
| 181 | |||
| 182 | message PublishEventRequest { | ||
| 183 | Event event = 1; | ||
| 184 | } | ||
| 185 | |||
| 186 | message PublishEventResponse { | ||
| 187 | bool accepted = 1; | ||
| 188 | string message = 2; // Error message or "duplicate" or "success" | ||
| 189 | |||
| 190 | // Always include canonical JSON so client can verify | ||
| 191 | // what the relay stored | ||
| 192 | bytes canonical_json = 3; | ||
| 193 | } | ||
| 194 | |||
| 195 | message SubscribeRequest { | ||
| 196 | repeated Filter filters = 1; | ||
| 197 | |||
| 198 | // If true, include canonical_json in streamed Event messages | ||
| 199 | // Allows client-side signature verification | ||
| 200 | // Default: false (most clients trust the relay) | ||
| 201 | bool include_canonical_json = 2; | ||
| 202 | |||
| 203 | // Optional client-provided subscription ID for tracking | ||
| 204 | string subscription_id = 3; | ||
| 205 | } | ||
| 206 | |||
| 207 | message UnsubscribeRequest { | ||
| 208 | string subscription_id = 1; | ||
| 209 | } | ||
| 210 | |||
| 211 | message PublishBatchRequest { | ||
| 212 | repeated Event events = 1; | ||
| 213 | } | ||
| 214 | |||
| 215 | message PublishBatchResponse { | ||
| 216 | repeated PublishEventResponse results = 1; | ||
| 217 | } | ||
| 218 | |||
| 219 | message QueryRequest { | ||
| 220 | repeated Filter filters = 1; | ||
| 221 | bool include_canonical_json = 2; | ||
| 222 | |||
| 223 | // Pagination | ||
| 224 | string cursor = 3; // Opaque cursor from previous response | ||
| 225 | int32 page_size = 4; // Default: 100 | ||
| 226 | } | ||
| 227 | |||
| 228 | message QueryResponse { | ||
| 229 | repeated Event events = 1; | ||
| 230 | string next_cursor = 2; // Empty if no more results | ||
| 231 | int32 total_count = 3; // Optional: total matching events | ||
| 232 | } | ||
| 233 | |||
| 234 | message CountRequest { | ||
| 235 | repeated Filter filters = 1; | ||
| 236 | } | ||
| 237 | |||
| 238 | message CountResponse { | ||
| 239 | int64 count = 1; | ||
| 240 | } | ||
| 241 | |||
| 242 | message Empty {} | ||
| 243 | |||
| 244 | // Admin service (optional, secured separately) | ||
| 245 | service RelayAdmin { | ||
| 246 | rpc GetStats(Empty) returns (RelayStats); | ||
| 247 | rpc GetConnections(Empty) returns (ConnectionList); | ||
| 248 | rpc BanPublicKey(BanRequest) returns (Empty); | ||
| 249 | rpc GetStorageInfo(Empty) returns (StorageStats); | ||
| 250 | } | ||
| 251 | |||
| 252 | message RelayStats { | ||
| 253 | int64 total_events = 1; | ||
| 254 | int64 total_subscriptions = 2; | ||
| 255 | int64 connected_clients = 3; | ||
| 256 | int64 events_per_second = 4; | ||
| 257 | int64 uptime_seconds = 5; | ||
| 258 | } | ||
| 259 | |||
| 260 | message ConnectionList { | ||
| 261 | repeated Connection connections = 1; | ||
| 262 | } | ||
| 263 | |||
| 264 | message Connection { | ||
| 265 | string client_id = 1; | ||
| 266 | string protocol = 2; // "websocket" or "grpc" | ||
| 267 | int64 connected_at = 3; | ||
| 268 | int32 active_subscriptions = 4; | ||
| 269 | } | ||
| 270 | |||
| 271 | message BanRequest { | ||
| 272 | string pubkey = 1; | ||
| 273 | int64 until = 2; // Unix timestamp, 0 for permanent | ||
| 274 | string reason = 3; | ||
| 275 | } | ||
| 276 | |||
| 277 | message StorageStats { | ||
| 278 | int64 total_bytes = 1; | ||
| 279 | int64 total_events = 2; | ||
| 280 | int64 db_size_bytes = 3; | ||
| 281 | } | ||
| 282 | ``` | ||
| 283 | |||
| 284 | ## Database Schema (SQLite) | ||
| 285 | |||
| 286 | ```sql | ||
| 287 | -- Main events table | ||
| 288 | CREATE TABLE events ( | ||
| 289 | -- Primary event data | ||
| 290 | id TEXT PRIMARY KEY, | ||
| 291 | event_data BLOB NOT NULL, -- Protobuf binary | ||
| 292 | canonical_json BLOB NOT NULL, -- zstd compressed canonical JSON | ||
| 293 | |||
| 294 | -- Denormalized fields for efficient querying | ||
| 295 | pubkey TEXT NOT NULL, | ||
| 296 | kind INTEGER NOT NULL, | ||
| 297 | created_at INTEGER NOT NULL, -- Unix timestamp | ||
| 298 | content TEXT, -- For full-text search (optional) | ||
| 299 | tags JSON, -- SQLite JSON for tag queries | ||
| 300 | sig TEXT NOT NULL, | ||
| 301 | |||
| 302 | -- Metadata | ||
| 303 | deleted BOOLEAN DEFAULT 0, | ||
| 304 | received_at INTEGER DEFAULT (unixepoch()) | ||
| 305 | ); | ||
| 306 | |||
| 307 | -- Critical indexes for Nostr query patterns | ||
| 308 | CREATE INDEX idx_pubkey_created | ||
| 309 | ON events(pubkey, created_at DESC) | ||
| 310 | WHERE deleted = 0; | ||
| 311 | |||
| 312 | CREATE INDEX idx_kind_created | ||
| 313 | ON events(kind, created_at DESC) | ||
| 314 | WHERE deleted = 0; | ||
| 315 | |||
| 316 | CREATE INDEX idx_created | ||
| 317 | ON events(created_at DESC) | ||
| 318 | WHERE deleted = 0; | ||
| 319 | |||
| 320 | -- For tag queries (#e, #p, etc) | ||
| 321 | CREATE INDEX idx_tags | ||
| 322 | ON events(tags) | ||
| 323 | WHERE deleted = 0; | ||
| 324 | |||
| 325 | -- Optional: full-text search on content | ||
| 326 | CREATE VIRTUAL TABLE events_fts USING fts5( | ||
| 327 | id UNINDEXED, | ||
| 328 | content, | ||
| 329 | content=events, | ||
| 330 | content_rowid=rowid | ||
| 331 | ); | ||
| 332 | |||
| 333 | -- Deletion events (NIP-09) | ||
| 334 | CREATE TABLE deletions ( | ||
| 335 | event_id TEXT PRIMARY KEY, -- ID of deletion event | ||
| 336 | deleted_event_id TEXT NOT NULL, -- ID of event being deleted | ||
| 337 | pubkey TEXT NOT NULL, -- Who requested deletion | ||
| 338 | created_at INTEGER NOT NULL, | ||
| 339 | FOREIGN KEY (deleted_event_id) REFERENCES events(id) | ||
| 340 | ); | ||
| 341 | |||
| 342 | CREATE INDEX idx_deleted_event ON deletions(deleted_event_id); | ||
| 343 | |||
| 344 | -- Replaceable events tracking (NIP-16, NIP-33) | ||
| 345 | CREATE TABLE replaceable_events ( | ||
| 346 | kind INTEGER NOT NULL, | ||
| 347 | pubkey TEXT NOT NULL, | ||
| 348 | d_tag TEXT, -- For parameterized replaceable events | ||
| 349 | current_event_id TEXT NOT NULL, | ||
| 350 | created_at INTEGER NOT NULL, | ||
| 351 | PRIMARY KEY (kind, pubkey, COALESCE(d_tag, '')), | ||
| 352 | FOREIGN KEY (current_event_id) REFERENCES events(id) | ||
| 353 | ); | ||
| 354 | |||
| 355 | -- Auth challenges (NIP-42) | ||
| 356 | CREATE TABLE auth_challenges ( | ||
| 357 | challenge TEXT PRIMARY KEY, | ||
| 358 | created_at INTEGER NOT NULL, | ||
| 359 | expires_at INTEGER NOT NULL, | ||
| 360 | used BOOLEAN DEFAULT 0 | ||
| 361 | ); | ||
| 362 | |||
| 363 | -- Rate limiting | ||
| 364 | CREATE TABLE rate_limits ( | ||
| 365 | pubkey TEXT PRIMARY KEY, | ||
| 366 | event_count INTEGER DEFAULT 0, | ||
| 367 | window_start INTEGER NOT NULL, | ||
| 368 | last_reset INTEGER DEFAULT (unixepoch()) | ||
| 369 | ); | ||
| 370 | ``` | ||
| 371 | |||
| 372 | ## Data Flow | ||
| 373 | |||
| 374 | ### Event Ingestion (WebSocket or gRPC) | ||
| 375 | |||
| 376 | ``` | ||
| 377 | 1. Event arrives (JSON via WebSocket OR protobuf via gRPC) | ||
| 378 | |||
| 379 | 2. Parse/Deserialize | ||
| 380 | - WebSocket: Parse JSON | ||
| 381 | - gRPC: Deserialize protobuf | ||
| 382 | |||
| 383 | 3. Construct Canonical JSON | ||
| 384 | - WebSocket: Already have it | ||
| 385 | - gRPC: Generate from protobuf | ||
| 386 | Format: [0, pubkey, created_at, kind, tags, content] | ||
| 387 | |||
| 388 | 4. Verify Signature | ||
| 389 | - Compute: SHA256(canonical_json) | ||
| 390 | - Verify: schnorr_verify(sig, hash, pubkey) | ||
| 391 | - If invalid: REJECT immediately | ||
| 392 | |||
| 393 | 5. Convert to Protobuf (if from WebSocket) | ||
| 394 | |||
| 395 | 6. Compress Canonical JSON | ||
| 396 | - Use zstd compression | ||
| 397 | - ~60% size reduction | ||
| 398 | |||
| 399 | 7. Store in SQLite | ||
| 400 | INSERT INTO events ( | ||
| 401 | id, event_data, canonical_json, | ||
| 402 | pubkey, kind, created_at, tags, ... | ||
| 403 | ) VALUES (?, ?, ?, ?, ?, ?, ?) | ||
| 404 | |||
| 405 | 8. Handle Special Cases | ||
| 406 | - NIP-09 deletion: Mark target event as deleted | ||
| 407 | - NIP-16 replaceable: Update replaceable_events table | ||
| 408 | - NIP-33 parameterized: Handle d-tag | ||
| 409 | |||
| 410 | 9. Notify Active Subscriptions | ||
| 411 | - Check all active filters | ||
| 412 | - Fan out to matching subscribers | ||
| 413 | ``` | ||
| 414 | |||
| 415 | ### Subscription Flow (gRPC) | ||
| 416 | |||
| 417 | ``` | ||
| 418 | 1. Client calls Subscribe(filters, include_canonical_json) | ||
| 419 | |||
| 420 | 2. Build SQL Query | ||
| 421 | - Convert Nostr filters to SQL WHERE clauses | ||
| 422 | - Handle multiple filters with UNION | ||
| 423 | - Add ORDER BY created_at DESC | ||
| 424 | - Apply LIMIT | ||
| 425 | |||
| 426 | 3. Execute Query | ||
| 427 | SELECT | ||
| 428 | event_data, | ||
| 429 | CASE WHEN ? THEN canonical_json ELSE NULL END | ||
| 430 | FROM events | ||
| 431 | WHERE <filter conditions> | ||
| 432 | ORDER BY created_at DESC | ||
| 433 | LIMIT ? | ||
| 434 | |||
| 435 | 4. For Each Row: | ||
| 436 | - Deserialize protobuf (event_data) | ||
| 437 | - If client wants verification: | ||
| 438 | * Decompress canonical_json | ||
| 439 | * Add to Event.canonical_json field | ||
| 440 | - Stream Event to client | ||
| 441 | |||
| 442 | 5. Send EOSE (End of Stored Events) | ||
| 443 | - Could be metadata in stream or final message | ||
| 444 | |||
| 445 | 6. Keep Subscription Active | ||
| 446 | - New matching events → stream to client | ||
| 447 | - Client closes stream → cleanup subscription | ||
| 448 | ``` | ||
| 449 | |||
| 450 | ### WebSocket Flow (Standard Nostr) | ||
| 451 | |||
| 452 | ``` | ||
| 453 | 1. Client sends: ["REQ", "sub-id", {...filter...}] | ||
| 454 | |||
| 455 | 2. Convert Nostr Filter to Internal Filter | ||
| 456 | - Same as gRPC Subscribe | ||
| 457 | |||
| 458 | 3. Execute Query (same as above) | ||
| 459 | |||
| 460 | 4. For Each Event: | ||
| 461 | - Query: SELECT event_data, canonical_json FROM events | ||
| 462 | - Deserialize protobuf | ||
| 463 | - Convert protobuf → JSON | ||
| 464 | - Send: ["EVENT", "sub-id", {...json_event...}] | ||
| 465 | |||
| 466 | 5. Send: ["EOSE", "sub-id"] | ||
| 467 | |||
| 468 | 6. Keep Subscription Active | ||
| 469 | - New events → convert to JSON → send EVENT message | ||
| 470 | |||
| 471 | 7. Client sends: ["CLOSE", "sub-id"] | ||
| 472 | - Cleanup subscription | ||
| 473 | ``` | ||
| 474 | |||
| 475 | ## Implementation Phases | ||
| 476 | |||
| 477 | ### Phase 1: Core Relay (WebSocket Only) | ||
| 478 | **Goal**: Working Nostr relay with binary storage | ||
| 479 | |||
| 480 | - [ ] SQLite setup with schema | ||
| 481 | - [ ] Event validation (ID, signature verification) | ||
| 482 | - [ ] Canonical JSON generation | ||
| 483 | - [ ] Basic storage (protobuf + compressed JSON) | ||
| 484 | - [ ] WebSocket server (NIP-01) | ||
| 485 | - [ ] Filter → SQL query conversion | ||
| 486 | - [ ] Subscription management | ||
| 487 | - [ ] Event fan-out to subscribers | ||
| 488 | |||
| 489 | **Deliverable**: Can use with any Nostr client | ||
| 490 | |||
| 491 | ### Phase 2: gRPC Parity | ||
| 492 | **Goal**: Prove dual-protocol concept works | ||
| 493 | |||
| 494 | - [ ] Protocol Buffer definitions | ||
| 495 | - [ ] gRPC server setup | ||
| 496 | - [ ] Implement PublishEvent, Subscribe, Unsubscribe | ||
| 497 | - [ ] Share subscription manager between protocols | ||
| 498 | - [ ] Test gRPC clients can pub/sub | ||
| 499 | |||
| 500 | **Deliverable**: Same functionality via gRPC | ||
| 501 | |||
| 502 | ### Phase 3: gRPC Extensions | ||
| 503 | **Goal**: Leverage gRPC advantages | ||
| 504 | |||
| 505 | - [ ] Batch publishing (PublishBatch) | ||
| 506 | - [ ] Pagination (QueryEvents with cursors) | ||
| 507 | - [ ] Event counts (CountEvents) | ||
| 508 | - [ ] Client-side verification support (canonical_json field) | ||
| 509 | - [ ] Performance optimizations | ||
| 510 | |||
| 511 | **Deliverable**: gRPC offers features WebSocket can't | ||
| 512 | |||
| 513 | ### Phase 4: Advanced Features | ||
| 514 | **Goal**: Production-ready relay | ||
| 515 | |||
| 516 | - [ ] NIP-09 (event deletion) | ||
| 517 | - [ ] NIP-11 (relay info document) | ||
| 518 | - [ ] NIP-42 (authentication) | ||
| 519 | - [ ] Rate limiting | ||
| 520 | - [ ] Admin API (stats, bans, monitoring) | ||
| 521 | - [ ] Full-text search (optional) | ||
| 522 | - [ ] Metrics/observability (Prometheus) | ||
| 523 | |||
| 524 | **Deliverable**: Feature-complete relay | ||
| 525 | |||
| 526 | ### Phase 5: Optimization | ||
| 527 | **Goal**: High performance at scale | ||
| 528 | |||
| 529 | - [ ] Connection pooling | ||
| 530 | - [ ] Event caching (in-memory hot events) | ||
| 531 | - [ ] Query optimization (EXPLAIN QUERY PLAN) | ||
| 532 | - [ ] Compression tuning | ||
| 533 | - [ ] Load testing (thousands of concurrent subscriptions) | ||
| 534 | |||
| 535 | ## NIPs (Nostr Implementation Possibilities) Support | ||
| 536 | |||
| 537 | ### Required (Phase 1) | ||
| 538 | - **NIP-01**: Basic protocol (EVENT, REQ, CLOSE) | ||
| 539 | - Event structure | ||
| 540 | - Filters | ||
| 541 | - Subscriptions | ||
| 542 | |||
| 543 | ### Recommended (Phase 4) | ||
| 544 | - **NIP-09**: Event deletion | ||
| 545 | - **NIP-11**: Relay information document (JSON at relay URL) | ||
| 546 | - **NIP-16**: Replaceable events (kind 0, 3, 41) | ||
| 547 | - **NIP-33**: Parameterized replaceable events (kind 30000-39999) | ||
| 548 | - **NIP-42**: Authentication | ||
| 549 | - **NIP-45**: Event counts | ||
| 550 | |||
| 551 | ### Optional | ||
| 552 | - **NIP-40**: Expiration timestamp | ||
| 553 | - **NIP-50**: Search (full-text) | ||
| 554 | - **NIP-65**: Relay list metadata | ||
| 555 | |||
| 556 | ## Configuration | ||
| 557 | |||
| 558 | ```yaml | ||
| 559 | # config.yaml | ||
| 560 | relay: | ||
| 561 | name: "My gRPC Nostr Relay" | ||
| 562 | description: "High-performance relay with gRPC support" | ||
| 563 | pubkey: "relay_operator_pubkey" | ||
| 564 | contact: "admin@example.com" | ||
| 565 | |||
| 566 | network: | ||
| 567 | websocket: | ||
| 568 | enabled: true | ||
| 569 | host: "0.0.0.0" | ||
| 570 | port: 8080 | ||
| 571 | max_connections: 10000 | ||
| 572 | |||
| 573 | grpc: | ||
| 574 | enabled: true | ||
| 575 | host: "0.0.0.0" | ||
| 576 | port: 50051 | ||
| 577 | max_connections: 10000 | ||
| 578 | |||
| 579 | tls: | ||
| 580 | enabled: false | ||
| 581 | cert_file: "/path/to/cert.pem" | ||
| 582 | key_file: "/path/to/key.pem" | ||
| 583 | |||
| 584 | storage: | ||
| 585 | engine: "sqlite" | ||
| 586 | path: "/var/lib/nostr-relay/relay.db" | ||
| 587 | |||
| 588 | # SQLite-specific | ||
| 589 | journal_mode: "WAL" | ||
| 590 | synchronous: "NORMAL" | ||
| 591 | cache_size: 10000 # pages | ||
| 592 | |||
| 593 | limits: | ||
| 594 | max_event_size: 65536 # bytes | ||
| 595 | max_subscriptions_per_client: 20 | ||
| 596 | max_filters_per_subscription: 10 | ||
| 597 | max_limit: 5000 # max events in one query | ||
| 598 | |||
| 599 | rate_limiting: | ||
| 600 | enabled: true | ||
| 601 | events_per_minute: 60 | ||
| 602 | window_size: 60 # seconds | ||
| 603 | |||
| 604 | retention: | ||
| 605 | enabled: false | ||
| 606 | max_age_days: 365 # Delete events older than this | ||
| 607 | kinds_exempt: [0, 3] # Don't delete these kinds | ||
| 608 | |||
| 609 | nips: | ||
| 610 | supported: [1, 9, 11, 16, 33, 42, 45] | ||
| 611 | ``` | ||
| 612 | |||
| 613 | ## Performance Targets | ||
| 614 | |||
| 615 | ### Storage | ||
| 616 | - **1M events**: ~550 MB (protobuf + compressed JSON) | ||
| 617 | - **10M events**: ~5.5 GB | ||
| 618 | - **Compression ratio**: ~40% savings vs pure JSON | ||
| 619 | |||
| 620 | ### Throughput | ||
| 621 | - **Writes**: 1,000-5,000 events/sec (single writer, SQLite WAL) | ||
| 622 | - **Reads**: 10,000-50,000 queries/sec (depends on query complexity) | ||
| 623 | - **Subscriptions**: Support 10,000+ concurrent subscriptions | ||
| 624 | |||
| 625 | ### Latency | ||
| 626 | - **gRPC publish**: <5ms (signature verification dominates) | ||
| 627 | - **gRPC subscribe**: <2ms first event (hot path) | ||
| 628 | - **WebSocket publish**: <6ms (includes JSON→proto conversion) | ||
| 629 | - **WebSocket subscribe**: <3ms first event (includes proto→JSON) | ||
| 630 | |||
| 631 | ## Testing Strategy | ||
| 632 | |||
| 633 | ### Unit Tests | ||
| 634 | - Event validation (signature, ID) | ||
| 635 | - Canonical JSON generation | ||
| 636 | - Filter → SQL conversion | ||
| 637 | - Protobuf ↔ JSON conversion | ||
| 638 | |||
| 639 | ### Integration Tests | ||
| 640 | - WebSocket protocol compliance | ||
| 641 | - gRPC service methods | ||
| 642 | - Concurrent subscriptions | ||
| 643 | - Event deletion and replacement | ||
| 644 | - Rate limiting | ||
| 645 | |||
| 646 | ### Load Tests | ||
| 647 | - 1000 concurrent WebSocket connections | ||
| 648 | - 1000 concurrent gRPC streams | ||
| 649 | - Event ingestion at 1000/sec | ||
| 650 | - Memory usage under load | ||
| 651 | |||
| 652 | ### Compatibility Tests | ||
| 653 | - Standard Nostr clients (Damus, Amethyst, Iris) | ||
| 654 | - gRPC clients (generated from .proto) | ||
| 655 | - Relay compatibility matrix | ||
| 656 | |||
| 657 | ## Security Considerations | ||
| 658 | |||
| 659 | ### Signature Verification | ||
| 660 | - Always verify schnorr signatures at ingestion | ||
| 661 | - Use well-tested crypto library (btcec, secp256k1) | ||
| 662 | - Reject malformed events immediately | ||
| 663 | |||
| 664 | ### Rate Limiting | ||
| 665 | - Per-pubkey limits on event publishing | ||
| 666 | - Per-IP limits on connections | ||
| 667 | - Per-subscription limits on query complexity | ||
| 668 | |||
| 669 | ### Authentication (NIP-42) | ||
| 670 | - Challenge-response auth for WebSocket | ||
| 671 | - Optional: require auth for publishing | ||
| 672 | - Configurable auth policies | ||
| 673 | |||
| 674 | ### DoS Prevention | ||
| 675 | - Max event size (64KB default) | ||
| 676 | - Max subscriptions per client | ||
| 677 | - Max filters per subscription | ||
| 678 | - Max results per query | ||
| 679 | - Connection timeouts | ||
| 680 | |||
| 681 | ### Input Validation | ||
| 682 | - Validate all event fields | ||
| 683 | - Sanitize SQL inputs (use parameterized queries) | ||
| 684 | - Limit JSON depth in tags | ||
| 685 | - Validate timestamps (not too far in future) | ||
| 686 | |||
| 687 | ## Monitoring & Observability | ||
| 688 | |||
| 689 | ### Metrics (Prometheus) | ||
| 690 | ``` | ||
| 691 | # Counters | ||
| 692 | nostr_events_received_total{protocol="websocket|grpc", valid="true|false"} | ||
| 693 | nostr_subscriptions_total{protocol="websocket|grpc"} | ||
| 694 | nostr_events_published_total | ||
| 695 | nostr_signature_verifications_total{valid="true|false"} | ||
| 696 | |||
| 697 | # Gauges | ||
| 698 | nostr_active_subscriptions{protocol="websocket|grpc"} | ||
| 699 | nostr_connected_clients{protocol="websocket|grpc"} | ||
| 700 | nostr_db_size_bytes | ||
| 701 | nostr_events_stored_total | ||
| 702 | |||
| 703 | # Histograms | ||
| 704 | nostr_event_publish_duration_seconds | ||
| 705 | nostr_query_duration_seconds | ||
| 706 | nostr_subscription_duration_seconds | ||
| 707 | ``` | ||
| 708 | |||
| 709 | ### Logging | ||
| 710 | - Structured logging (JSON) | ||
| 711 | - Log levels: DEBUG, INFO, WARN, ERROR | ||
| 712 | - Include: timestamp, level, component, message, context | ||
| 713 | |||
| 714 | ### Health Checks | ||
| 715 | - `/health` endpoint (HTTP) | ||
| 716 | - gRPC health service | ||
| 717 | - Database connectivity | ||
| 718 | - Disk space checks | ||
| 719 | |||
| 720 | ## Deployment | ||
| 721 | |||
| 722 | ### Docker | ||
| 723 | ```dockerfile | ||
| 724 | FROM golang:1.22-alpine AS builder | ||
| 725 | WORKDIR /build | ||
| 726 | COPY . . | ||
| 727 | RUN go build -o relay cmd/relay/main.go | ||
| 728 | |||
| 729 | FROM alpine:latest | ||
| 730 | RUN apk --no-cache add ca-certificates | ||
| 731 | COPY --from=builder /build/relay /relay | ||
| 732 | COPY config.yaml /config.yaml | ||
| 733 | EXPOSE 8080 50051 | ||
| 734 | CMD ["/relay", "--config", "/config.yaml"] | ||
| 735 | ``` | ||
| 736 | |||
| 737 | ### Docker Compose | ||
| 738 | ```yaml | ||
| 739 | version: '3.8' | ||
| 740 | services: | ||
| 741 | relay: | ||
| 742 | build: . | ||
| 743 | ports: | ||
| 744 | - "8080:8080" # WebSocket | ||
| 745 | - "50051:50051" # gRPC | ||
| 746 | volumes: | ||
| 747 | - ./data:/var/lib/nostr-relay | ||
| 748 | - ./config.yaml:/config.yaml | ||
| 749 | environment: | ||
| 750 | - LOG_LEVEL=info | ||
| 751 | restart: unless-stopped | ||
| 752 | ``` | ||
| 753 | |||
| 754 | ### Systemd | ||
| 755 | ```ini | ||
| 756 | [Unit] | ||
| 757 | Description=Nostr gRPC Relay | ||
| 758 | After=network.target | ||
| 759 | |||
| 760 | [Service] | ||
| 761 | Type=simple | ||
| 762 | User=nostr | ||
| 763 | WorkingDirectory=/opt/nostr-relay | ||
| 764 | ExecStart=/opt/nostr-relay/relay --config /etc/nostr-relay/config.yaml | ||
| 765 | Restart=always | ||
| 766 | RestartSec=10 | ||
| 767 | |||
| 768 | [Install] | ||
| 769 | WantedBy=multi-user.target | ||
| 770 | ``` | ||
| 771 | |||
| 772 | ## Project Structure | ||
| 773 | |||
| 774 | ``` | ||
| 775 | nostr-grpc-relay/ | ||
| 776 | ├── cmd/ | ||
| 777 | │ └── relay/ | ||
| 778 | │ └── main.go # Entry point | ||
| 779 | ├── internal/ | ||
| 780 | │ ├── config/ | ||
| 781 | │ │ └── config.go # Configuration loading | ||
| 782 | │ ├── storage/ | ||
| 783 | │ │ ├── sqlite.go # SQLite implementation | ||
| 784 | │ │ ├── schema.sql # Database schema | ||
| 785 | │ │ └── queries.go # SQL query builders | ||
| 786 | │ ├── nostr/ | ||
| 787 | │ │ ├── event.go # Event validation | ||
| 788 | │ │ ├── filter.go # Filter handling | ||
| 789 | │ │ ├── signature.go # Signature verification | ||
| 790 | │ │ └── canonical.go # Canonical JSON generation | ||
| 791 | │ ├── subscription/ | ||
| 792 | │ │ └── manager.go # Subscription management | ||
| 793 | │ ├── transport/ | ||
| 794 | │ │ ├── websocket/ | ||
| 795 | │ │ │ └── server.go # WebSocket handler | ||
| 796 | │ │ └── grpc/ | ||
| 797 | │ │ └── server.go # gRPC server | ||
| 798 | │ └── metrics/ | ||
| 799 | │ └── prometheus.go # Metrics collection | ||
| 800 | ├── pkg/ | ||
| 801 | │ └── pb/ | ||
| 802 | │ └── nostr.proto # Protocol Buffer definitions | ||
| 803 | ├── test/ | ||
| 804 | │ ├── integration/ | ||
| 805 | │ └── load/ | ||
| 806 | ├── scripts/ | ||
| 807 | │ └── setup-db.sh # Database initialization | ||
| 808 | ├── config.yaml # Default configuration | ||
| 809 | ├── Dockerfile | ||
| 810 | ├── docker-compose.yaml | ||
| 811 | ├── go.mod | ||
| 812 | ├── go.sum | ||
| 813 | └── README.md | ||
| 814 | ``` | ||
| 815 | |||
| 816 | ## Dependencies | ||
| 817 | |||
| 818 | ```go | ||
| 819 | // go.mod | ||
| 820 | module github.com/yourusername/nostr-grpc-relay | ||
| 821 | |||
| 822 | go 1.22 | ||
| 823 | |||
| 824 | require ( | ||
| 825 | // Storage | ||
| 826 | modernc.org/sqlite v1.28.0 | ||
| 827 | |||
| 828 | // gRPC & Protobuf | ||
| 829 | google.golang.org/grpc v1.60.0 | ||
| 830 | google.golang.org/protobuf v1.32.0 | ||
| 831 | |||
| 832 | // WebSocket | ||
| 833 | github.com/gorilla/websocket v1.5.1 | ||
| 834 | |||
| 835 | // Nostr crypto | ||
| 836 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 | ||
| 837 | |||
| 838 | // Compression | ||
| 839 | github.com/klauspost/compress v1.17.4 | ||
| 840 | |||
| 841 | // Configuration | ||
| 842 | gopkg.in/yaml.v3 v3.0.1 | ||
| 843 | |||
| 844 | // Logging | ||
| 845 | go.uber.org/zap v1.26.0 | ||
| 846 | |||
| 847 | // Metrics | ||
| 848 | github.com/prometheus/client_golang v1.18.0 | ||
| 849 | ) | ||
| 850 | ``` | ||
| 851 | |||
| 852 | ## Success Criteria | ||
| 853 | |||
| 854 | This project is successful if: | ||
| 855 | |||
| 856 | 1. ✅ **Compatibility**: Any standard Nostr client can connect via WebSocket | ||
| 857 | 2. ✅ **Performance**: gRPC clients get <2ms latency for cached queries | ||
| 858 | 3. ✅ **Correctness**: 100% signature verification accuracy | ||
| 859 | 4. ✅ **Reliability**: 99.9% uptime under normal load | ||
| 860 | 5. ✅ **Scalability**: Handles 1M+ stored events efficiently | ||
| 861 | 6. ✅ **Developer Experience**: gRPC clients are easier to build than WebSocket | ||
| 862 | |||
| 863 | ## Future Extensions | ||
| 864 | |||
| 865 | ### Phase 6+: Advanced Features | ||
| 866 | - **Multi-relay federation**: Sync events between relays via gRPC | ||
| 867 | - **Event compression**: Store events with better compression (Brotli, Zstandard dictionaries) | ||
| 868 | - **Sharding**: Distribute events across multiple SQLite databases by pubkey | ||
| 869 | - **Read replicas**: PostgreSQL with read replicas for massive scale | ||
| 870 | - **Graph queries**: Social graph analysis (who follows who, degrees of separation) | ||
| 871 | - **Machine learning**: Spam detection, content classification | ||
| 872 | - **Search**: Full-text search with ranking, faceted search | ||
| 873 | |||
| 874 | ### gRPC-Specific Innovations | ||
| 875 | - **Streaming imports/exports**: Bulk event migration between relays | ||
| 876 | - **Transaction support**: Atomic multi-event operations | ||
| 877 | - **Server-side filtering**: Complex filters executed relay-side | ||
| 878 | - **Delta subscriptions**: Only send changed fields (bandwidth optimization) | ||
| 879 | - **Compression negotiation**: Per-client compression algorithms | ||
| 880 | |||
| 881 | ## References | ||
| 882 | |||
| 883 | - [Nostr Protocol (NIPs)](https://github.com/nostr-protocol/nips) | ||
| 884 | - [gRPC Documentation](https://grpc.io/docs/) | ||
| 885 | - [Protocol Buffers Guide](https://protobuf.dev/) | ||
| 886 | - [SQLite Documentation](https://www.sqlite.org/docs.html) | ||
| 887 | - [Schnorr Signatures (BIP340)](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) | ||
| 888 | |||
| 889 | ## Contact & Support | ||
| 890 | |||
| 891 | - **Issues**: GitHub Issues | ||
| 892 | - **Discussions**: GitHub Discussions | ||
| 893 | - **Nostr**: [Your Nostr Pubkey] | ||
| 894 | |||
| 895 | --- | ||
| 896 | |||
| 897 | **License**: MIT | ||
| 898 | |||
| 899 | **Version**: 1.0.0-alpha | ||
| 900 | |||
| 901 | **Last Updated**: 2026-02-13 | ||
