# Nostr gRPC Relay - Design Document ## Project Overview 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. ## Core Philosophy - **Transport as Implementation Detail**: Business logic is protocol-agnostic - **Binary-First Storage**: Events stored as protobuf, JSON generated on-demand for WebSocket clients - **Verify Once, Trust Forever**: Signature verification happens at ingestion only (events are immutable) - **Dual Protocol**: Full Nostr compatibility via WebSocket + extended features via gRPC ## Architecture ``` ┌─────────────────────────────────────────────────────────┐ │ Transport Layer │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ WebSocket │ │ gRPC │ │ │ │ (Nostr JSON) │ │ (Protobuf) │ │ │ │ - NIP-01 │ │ - Native API │ │ │ │ - NIP-09 │ │ - Extensions │ │ │ │ - NIP-11 │ │ - Admin API │ │ │ │ - NIP-42 │ │ │ │ │ └────────┬─────────┘ └────────┬─────────┘ │ │ │ │ │ │ └──────────┬───────────────┘ │ └──────────────────────┼──────────────────────────────────┘ │ ┌──────────────────────▼──────────────────────────────────┐ │ Application Layer │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Subscription Manager │ │ │ │ - Filter matching (shared across protocols) │ │ │ │ - Active subscription tracking │ │ │ │ - Event fan-out to subscribers │ │ │ │ - Deduplication │ │ │ └─────────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Event Processor │ │ │ │ - Signature validation (schnorr) │ │ │ │ - Event ID verification (SHA256) │ │ │ │ - Canonical JSON reconstruction │ │ │ │ - Rate limiting / spam filtering │ │ │ └─────────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Auth Manager │ │ │ │ - NIP-42 (WebSocket auth) │ │ │ │ - gRPC auth (mTLS, JWT, API keys) │ │ │ └─────────────────────────────────────────────────┘ │ └──────────────────────┬──────────────────────────────────┘ │ ┌──────────────────────▼──────────────────────────────────┐ │ Storage Layer (SQLite) │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Event Store │ │ │ │ - Binary protobuf storage (primary) │ │ │ │ - Compressed canonical JSON (for verification) │ │ │ │ - Denormalized indexes (pubkey, kind, etc) │ │ │ └─────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` ## Key Design Decisions ### 1. Binary-First Storage **Why**: - 40% smaller storage footprint - Zero serialization cost for gRPC clients - Faster queries on indexed fields **How**: - Store events as protobuf blobs - Maintain denormalized fields (pubkey, kind, created_at, tags) for indexing - Store compressed canonical JSON alongside for signature verification ### 2. Verify Signatures Once **Rationale**: - Nostr events are immutable (content-addressed by hash) - Once signature is verified at ingestion, it never needs re-checking - Clients already trust relays for many things (filtering, availability) **Implementation**: - Verify signature during event ingestion - Reject invalid events immediately - Store only valid events with `signature_valid=true` metadata - Clients can opt-in to receive canonical JSON for their own verification ### 3. Dual Storage for Correctness Store both protobuf AND canonical JSON: - **Protobuf** (`event_data`): Fast queries, native gRPC serving - **Canonical JSON** (`canonical_json`): Exact bytes that were signed, compressed with zstd **Storage overhead**: ~150 bytes per event (compressed JSON) - 1M events = 150MB extra - Worth it for correctness and debuggability ### 4. SQLite for Storage **Why SQLite**: - Complex queries (Nostr filters) map directly to SQL - Proven in production Nostr relays (strfry, nostream) - Built-in indexing and query optimization - JSON support for tag queries - WAL mode: 10k-50k reads/sec, 1k-5k writes/sec - Pure Go driver available (`modernc.org/sqlite`) **Why not BadgerDB/Pebble**: - Would require building custom query engine - Complex index management - More implementation work for same result ## Protocol Buffers Schema ```protobuf syntax = "proto3"; package nostr; // Core Nostr event message Event { string id = 1; string pubkey = 2; int64 created_at = 3; int32 kind = 4; repeated Tag tags = 5; string content = 6; string sig = 7; // Optional: only populated if client requests verification // Contains exact canonical JSON bytes that were signed optional bytes canonical_json = 8; } message Tag { repeated string values = 1; // e.g., ["e", "event_id", "relay_url"] } // Nostr filter (REQ) message Filter { repeated string ids = 1; repeated string authors = 2; // pubkeys repeated int32 kinds = 3; repeated string e_tags = 4; // #e tag values repeated string p_tags = 5; // #p tag values int64 since = 6; int64 until = 7; int32 limit = 8; // Extension: support for arbitrary tag filters map tag_filters = 9; } message TagFilter { repeated string values = 1; } // gRPC Services service NostrRelay { // Publish a single event rpc PublishEvent(PublishEventRequest) returns (PublishEventResponse); // Subscribe to events matching filters (streaming) rpc Subscribe(SubscribeRequest) returns (stream Event); // Unsubscribe from an active subscription rpc Unsubscribe(UnsubscribeRequest) returns (Empty); // gRPC-specific: batch publish rpc PublishBatch(PublishBatchRequest) returns (PublishBatchResponse); // gRPC-specific: paginated query (non-streaming) rpc QueryEvents(QueryRequest) returns (QueryResponse); // Event counts (NIP-45) rpc CountEvents(CountRequest) returns (CountResponse); } message PublishEventRequest { Event event = 1; } message PublishEventResponse { bool accepted = 1; string message = 2; // Error message or "duplicate" or "success" // Always include canonical JSON so client can verify // what the relay stored bytes canonical_json = 3; } message SubscribeRequest { repeated Filter filters = 1; // If true, include canonical_json in streamed Event messages // Allows client-side signature verification // Default: false (most clients trust the relay) bool include_canonical_json = 2; // Optional client-provided subscription ID for tracking string subscription_id = 3; } message UnsubscribeRequest { string subscription_id = 1; } message PublishBatchRequest { repeated Event events = 1; } message PublishBatchResponse { repeated PublishEventResponse results = 1; } message QueryRequest { repeated Filter filters = 1; bool include_canonical_json = 2; // Pagination string cursor = 3; // Opaque cursor from previous response int32 page_size = 4; // Default: 100 } message QueryResponse { repeated Event events = 1; string next_cursor = 2; // Empty if no more results int32 total_count = 3; // Optional: total matching events } message CountRequest { repeated Filter filters = 1; } message CountResponse { int64 count = 1; } message Empty {} // Admin service (optional, secured separately) service RelayAdmin { rpc GetStats(Empty) returns (RelayStats); rpc GetConnections(Empty) returns (ConnectionList); rpc BanPublicKey(BanRequest) returns (Empty); rpc GetStorageInfo(Empty) returns (StorageStats); } message RelayStats { int64 total_events = 1; int64 total_subscriptions = 2; int64 connected_clients = 3; int64 events_per_second = 4; int64 uptime_seconds = 5; } message ConnectionList { repeated Connection connections = 1; } message Connection { string client_id = 1; string protocol = 2; // "websocket" or "grpc" int64 connected_at = 3; int32 active_subscriptions = 4; } message BanRequest { string pubkey = 1; int64 until = 2; // Unix timestamp, 0 for permanent string reason = 3; } message StorageStats { int64 total_bytes = 1; int64 total_events = 2; int64 db_size_bytes = 3; } ``` ## Database Schema (SQLite) ```sql -- Main events table CREATE TABLE events ( -- Primary event data id TEXT PRIMARY KEY, event_data BLOB NOT NULL, -- Protobuf binary canonical_json BLOB NOT NULL, -- zstd compressed canonical JSON -- Denormalized fields for efficient querying pubkey TEXT NOT NULL, kind INTEGER NOT NULL, created_at INTEGER NOT NULL, -- Unix timestamp content TEXT, -- For full-text search (optional) tags JSON, -- SQLite JSON for tag queries sig TEXT NOT NULL, -- Metadata deleted BOOLEAN DEFAULT 0, received_at INTEGER DEFAULT (unixepoch()) ); -- Critical indexes for Nostr query patterns CREATE INDEX idx_pubkey_created ON events(pubkey, created_at DESC) WHERE deleted = 0; CREATE INDEX idx_kind_created ON events(kind, created_at DESC) WHERE deleted = 0; CREATE INDEX idx_created ON events(created_at DESC) WHERE deleted = 0; -- For tag queries (#e, #p, etc) CREATE INDEX idx_tags ON events(tags) WHERE deleted = 0; -- Optional: full-text search on content CREATE VIRTUAL TABLE events_fts USING fts5( id UNINDEXED, content, content=events, content_rowid=rowid ); -- Deletion events (NIP-09) CREATE TABLE deletions ( event_id TEXT PRIMARY KEY, -- ID of deletion event deleted_event_id TEXT NOT NULL, -- ID of event being deleted pubkey TEXT NOT NULL, -- Who requested deletion created_at INTEGER NOT NULL, FOREIGN KEY (deleted_event_id) REFERENCES events(id) ); CREATE INDEX idx_deleted_event ON deletions(deleted_event_id); -- Replaceable events tracking (NIP-16, NIP-33) CREATE TABLE replaceable_events ( kind INTEGER NOT NULL, pubkey TEXT NOT NULL, d_tag TEXT, -- For parameterized replaceable events current_event_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (kind, pubkey, COALESCE(d_tag, '')), FOREIGN KEY (current_event_id) REFERENCES events(id) ); -- Auth challenges (NIP-42) CREATE TABLE auth_challenges ( challenge TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used BOOLEAN DEFAULT 0 ); -- Rate limiting CREATE TABLE rate_limits ( pubkey TEXT PRIMARY KEY, event_count INTEGER DEFAULT 0, window_start INTEGER NOT NULL, last_reset INTEGER DEFAULT (unixepoch()) ); ``` ## Data Flow ### Event Ingestion (WebSocket or gRPC) ``` 1. Event arrives (JSON via WebSocket OR protobuf via gRPC) 2. Parse/Deserialize - WebSocket: Parse JSON - gRPC: Deserialize protobuf 3. Construct Canonical JSON - WebSocket: Already have it - gRPC: Generate from protobuf Format: [0, pubkey, created_at, kind, tags, content] 4. Verify Signature - Compute: SHA256(canonical_json) - Verify: schnorr_verify(sig, hash, pubkey) - If invalid: REJECT immediately 5. Convert to Protobuf (if from WebSocket) 6. Compress Canonical JSON - Use zstd compression - ~60% size reduction 7. Store in SQLite INSERT INTO events ( id, event_data, canonical_json, pubkey, kind, created_at, tags, ... ) VALUES (?, ?, ?, ?, ?, ?, ?) 8. Handle Special Cases - NIP-09 deletion: Mark target event as deleted - NIP-16 replaceable: Update replaceable_events table - NIP-33 parameterized: Handle d-tag 9. Notify Active Subscriptions - Check all active filters - Fan out to matching subscribers ``` ### Subscription Flow (gRPC) ``` 1. Client calls Subscribe(filters, include_canonical_json) 2. Build SQL Query - Convert Nostr filters to SQL WHERE clauses - Handle multiple filters with UNION - Add ORDER BY created_at DESC - Apply LIMIT 3. Execute Query SELECT event_data, CASE WHEN ? THEN canonical_json ELSE NULL END FROM events WHERE ORDER BY created_at DESC LIMIT ? 4. For Each Row: - Deserialize protobuf (event_data) - If client wants verification: * Decompress canonical_json * Add to Event.canonical_json field - Stream Event to client 5. Send EOSE (End of Stored Events) - Could be metadata in stream or final message 6. Keep Subscription Active - New matching events → stream to client - Client closes stream → cleanup subscription ``` ### WebSocket Flow (Standard Nostr) ``` 1. Client sends: ["REQ", "sub-id", {...filter...}] 2. Convert Nostr Filter to Internal Filter - Same as gRPC Subscribe 3. Execute Query (same as above) 4. For Each Event: - Query: SELECT event_data, canonical_json FROM events - Deserialize protobuf - Convert protobuf → JSON - Send: ["EVENT", "sub-id", {...json_event...}] 5. Send: ["EOSE", "sub-id"] 6. Keep Subscription Active - New events → convert to JSON → send EVENT message 7. Client sends: ["CLOSE", "sub-id"] - Cleanup subscription ``` ## Implementation Phases ### Phase 1: Core Relay (WebSocket Only) **Goal**: Working Nostr relay with binary storage - [ ] SQLite setup with schema - [ ] Event validation (ID, signature verification) - [ ] Canonical JSON generation - [ ] Basic storage (protobuf + compressed JSON) - [ ] WebSocket server (NIP-01) - [ ] Filter → SQL query conversion - [ ] Subscription management - [ ] Event fan-out to subscribers **Deliverable**: Can use with any Nostr client ### Phase 2: gRPC Parity **Goal**: Prove dual-protocol concept works - [ ] Protocol Buffer definitions - [ ] gRPC server setup - [ ] Implement PublishEvent, Subscribe, Unsubscribe - [ ] Share subscription manager between protocols - [ ] Test gRPC clients can pub/sub **Deliverable**: Same functionality via gRPC ### Phase 3: gRPC Extensions **Goal**: Leverage gRPC advantages - [ ] Batch publishing (PublishBatch) - [ ] Pagination (QueryEvents with cursors) - [ ] Event counts (CountEvents) - [ ] Client-side verification support (canonical_json field) - [ ] Performance optimizations **Deliverable**: gRPC offers features WebSocket can't ### Phase 4: Advanced Features **Goal**: Production-ready relay - [ ] NIP-09 (event deletion) - [ ] NIP-11 (relay info document) - [ ] NIP-42 (authentication) - [ ] Rate limiting - [ ] Admin API (stats, bans, monitoring) - [ ] Full-text search (optional) - [ ] Metrics/observability (Prometheus) **Deliverable**: Feature-complete relay ### Phase 5: Optimization **Goal**: High performance at scale - [ ] Connection pooling - [ ] Event caching (in-memory hot events) - [ ] Query optimization (EXPLAIN QUERY PLAN) - [ ] Compression tuning - [ ] Load testing (thousands of concurrent subscriptions) ## NIPs (Nostr Implementation Possibilities) Support ### Required (Phase 1) - **NIP-01**: Basic protocol (EVENT, REQ, CLOSE) - Event structure - Filters - Subscriptions ### Recommended (Phase 4) - **NIP-09**: Event deletion - **NIP-11**: Relay information document (JSON at relay URL) - **NIP-16**: Replaceable events (kind 0, 3, 41) - **NIP-33**: Parameterized replaceable events (kind 30000-39999) - **NIP-42**: Authentication - **NIP-45**: Event counts ### Optional - **NIP-40**: Expiration timestamp - **NIP-50**: Search (full-text) - **NIP-65**: Relay list metadata ## Configuration ```yaml # config.yaml relay: name: "My gRPC Nostr Relay" description: "High-performance relay with gRPC support" pubkey: "relay_operator_pubkey" contact: "admin@example.com" network: websocket: enabled: true host: "0.0.0.0" port: 8080 max_connections: 10000 grpc: enabled: true host: "0.0.0.0" port: 50051 max_connections: 10000 tls: enabled: false cert_file: "/path/to/cert.pem" key_file: "/path/to/key.pem" storage: engine: "sqlite" path: "/var/lib/nostr-relay/relay.db" # SQLite-specific journal_mode: "WAL" synchronous: "NORMAL" cache_size: 10000 # pages limits: max_event_size: 65536 # bytes max_subscriptions_per_client: 20 max_filters_per_subscription: 10 max_limit: 5000 # max events in one query rate_limiting: enabled: true events_per_minute: 60 window_size: 60 # seconds retention: enabled: false max_age_days: 365 # Delete events older than this kinds_exempt: [0, 3] # Don't delete these kinds nips: supported: [1, 9, 11, 16, 33, 42, 45] ``` ## Performance Targets ### Storage - **1M events**: ~550 MB (protobuf + compressed JSON) - **10M events**: ~5.5 GB - **Compression ratio**: ~40% savings vs pure JSON ### Throughput - **Writes**: 1,000-5,000 events/sec (single writer, SQLite WAL) - **Reads**: 10,000-50,000 queries/sec (depends on query complexity) - **Subscriptions**: Support 10,000+ concurrent subscriptions ### Latency - **gRPC publish**: <5ms (signature verification dominates) - **gRPC subscribe**: <2ms first event (hot path) - **WebSocket publish**: <6ms (includes JSON→proto conversion) - **WebSocket subscribe**: <3ms first event (includes proto→JSON) ## Testing Strategy ### Unit Tests - Event validation (signature, ID) - Canonical JSON generation - Filter → SQL conversion - Protobuf ↔ JSON conversion ### Integration Tests - WebSocket protocol compliance - gRPC service methods - Concurrent subscriptions - Event deletion and replacement - Rate limiting ### Load Tests - 1000 concurrent WebSocket connections - 1000 concurrent gRPC streams - Event ingestion at 1000/sec - Memory usage under load ### Compatibility Tests - Standard Nostr clients (Damus, Amethyst, Iris) - gRPC clients (generated from .proto) - Relay compatibility matrix ## Security Considerations ### Signature Verification - Always verify schnorr signatures at ingestion - Use well-tested crypto library (btcec, secp256k1) - Reject malformed events immediately ### Rate Limiting - Per-pubkey limits on event publishing - Per-IP limits on connections - Per-subscription limits on query complexity ### Authentication (NIP-42) - Challenge-response auth for WebSocket - Optional: require auth for publishing - Configurable auth policies ### DoS Prevention - Max event size (64KB default) - Max subscriptions per client - Max filters per subscription - Max results per query - Connection timeouts ### Input Validation - Validate all event fields - Sanitize SQL inputs (use parameterized queries) - Limit JSON depth in tags - Validate timestamps (not too far in future) ## Monitoring & Observability ### Metrics (Prometheus) ``` # Counters nostr_events_received_total{protocol="websocket|grpc", valid="true|false"} nostr_subscriptions_total{protocol="websocket|grpc"} nostr_events_published_total nostr_signature_verifications_total{valid="true|false"} # Gauges nostr_active_subscriptions{protocol="websocket|grpc"} nostr_connected_clients{protocol="websocket|grpc"} nostr_db_size_bytes nostr_events_stored_total # Histograms nostr_event_publish_duration_seconds nostr_query_duration_seconds nostr_subscription_duration_seconds ``` ### Logging - Structured logging (JSON) - Log levels: DEBUG, INFO, WARN, ERROR - Include: timestamp, level, component, message, context ### Health Checks - `/health` endpoint (HTTP) - gRPC health service - Database connectivity - Disk space checks ## Deployment ### Docker ```dockerfile FROM golang:1.22-alpine AS builder WORKDIR /build COPY . . RUN go build -o relay cmd/relay/main.go FROM alpine:latest RUN apk --no-cache add ca-certificates COPY --from=builder /build/relay /relay COPY config.yaml /config.yaml EXPOSE 8080 50051 CMD ["/relay", "--config", "/config.yaml"] ``` ### Docker Compose ```yaml version: '3.8' services: relay: build: . ports: - "8080:8080" # WebSocket - "50051:50051" # gRPC volumes: - ./data:/var/lib/nostr-relay - ./config.yaml:/config.yaml environment: - LOG_LEVEL=info restart: unless-stopped ``` ### Systemd ```ini [Unit] Description=Nostr gRPC Relay After=network.target [Service] Type=simple User=nostr WorkingDirectory=/opt/nostr-relay ExecStart=/opt/nostr-relay/relay --config /etc/nostr-relay/config.yaml Restart=always RestartSec=10 [Install] WantedBy=multi-user.target ``` ## Project Structure ``` nostr-grpc-relay/ ├── cmd/ │ └── relay/ │ └── main.go # Entry point ├── internal/ │ ├── config/ │ │ └── config.go # Configuration loading │ ├── storage/ │ │ ├── sqlite.go # SQLite implementation │ │ ├── schema.sql # Database schema │ │ └── queries.go # SQL query builders │ ├── nostr/ │ │ ├── event.go # Event validation │ │ ├── filter.go # Filter handling │ │ ├── signature.go # Signature verification │ │ └── canonical.go # Canonical JSON generation │ ├── subscription/ │ │ └── manager.go # Subscription management │ ├── transport/ │ │ ├── websocket/ │ │ │ └── server.go # WebSocket handler │ │ └── grpc/ │ │ └── server.go # gRPC server │ └── metrics/ │ └── prometheus.go # Metrics collection ├── pkg/ │ └── pb/ │ └── nostr.proto # Protocol Buffer definitions ├── test/ │ ├── integration/ │ └── load/ ├── scripts/ │ └── setup-db.sh # Database initialization ├── config.yaml # Default configuration ├── Dockerfile ├── docker-compose.yaml ├── go.mod ├── go.sum └── README.md ``` ## Dependencies ```go // go.mod module github.com/yourusername/nostr-grpc-relay go 1.22 require ( // Storage modernc.org/sqlite v1.28.0 // gRPC & Protobuf google.golang.org/grpc v1.60.0 google.golang.org/protobuf v1.32.0 // WebSocket github.com/gorilla/websocket v1.5.1 // Nostr crypto github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // Compression github.com/klauspost/compress v1.17.4 // Configuration gopkg.in/yaml.v3 v3.0.1 // Logging go.uber.org/zap v1.26.0 // Metrics github.com/prometheus/client_golang v1.18.0 ) ``` ## Success Criteria This project is successful if: 1. ✅ **Compatibility**: Any standard Nostr client can connect via WebSocket 2. ✅ **Performance**: gRPC clients get <2ms latency for cached queries 3. ✅ **Correctness**: 100% signature verification accuracy 4. ✅ **Reliability**: 99.9% uptime under normal load 5. ✅ **Scalability**: Handles 1M+ stored events efficiently 6. ✅ **Developer Experience**: gRPC clients are easier to build than WebSocket ## Future Extensions ### Phase 6+: Advanced Features - **Multi-relay federation**: Sync events between relays via gRPC - **Event compression**: Store events with better compression (Brotli, Zstandard dictionaries) - **Sharding**: Distribute events across multiple SQLite databases by pubkey - **Read replicas**: PostgreSQL with read replicas for massive scale - **Graph queries**: Social graph analysis (who follows who, degrees of separation) - **Machine learning**: Spam detection, content classification - **Search**: Full-text search with ranking, faceted search ### gRPC-Specific Innovations - **Streaming imports/exports**: Bulk event migration between relays - **Transaction support**: Atomic multi-event operations - **Server-side filtering**: Complex filters executed relay-side - **Delta subscriptions**: Only send changed fields (bandwidth optimization) - **Compression negotiation**: Per-client compression algorithms ## References - [Nostr Protocol (NIPs)](https://github.com/nostr-protocol/nips) - [gRPC Documentation](https://grpc.io/docs/) - [Protocol Buffers Guide](https://protobuf.dev/) - [SQLite Documentation](https://www.sqlite.org/docs.html) - [Schnorr Signatures (BIP340)](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) ## Contact & Support - **Issues**: GitHub Issues - **Discussions**: GitHub Discussions - **Nostr**: [Your Nostr Pubkey] --- **License**: MIT **Version**: 1.0.0-alpha **Last Updated**: 2026-02-13