From 3d400d4197840020ac471869497090483ade8e57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 12:34:51 +0000 Subject: [PATCH] docs: add CLIENT.md guide for building Nostr clients with Quartz Companion to the existing RELAY.md, this guide covers the client side: Ktor WebSocket transport implementation, subscribing to events (Flow and callback APIs), publishing signed events, filters, key management, and multi-relay patterns. https://claude.ai/code/session_01KFos33kA9VoMhKdBPNLquF --- quartz/CLIENT.md | 562 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 quartz/CLIENT.md diff --git a/quartz/CLIENT.md b/quartz/CLIENT.md new file mode 100644 index 000000000..c7d58b11d --- /dev/null +++ b/quartz/CLIENT.md @@ -0,0 +1,562 @@ +# Building a Nostr Client with Quartz + +This guide walks you through building a Nostr client using Quartz's `NostrClient` and [Ktor](https://ktor.io/) as the WebSocket transport layer. + +## Overview + +Quartz provides a complete, transport-agnostic client engine: + +| Component | Class | Role | +|-----------|-------|------| +| **NostrClient** | `com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient` | Manages relay connections, subscriptions, and event publishing | +| **WebsocketBuilder** | `com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder` | Factory interface for pluggable WebSocket transports | +| **Filter** | `com.vitorpamplona.quartz.nip01Core.relay.filters.Filter` | NIP-01 subscription filters | +| **KeyPair** | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` | Schnorr key pair for signing events | +| **NostrSignerInternal** | `com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal` | Signs events with a local private key | + +`NostrClient` doesn't know about HTTP or WebSockets. You provide a `WebsocketBuilder` that creates transport connections, and the client handles relay pooling, subscription management, reconnection, and event delivery. + +## Quick Start + +### 1. Add Dependencies + +In your `build.gradle.kts`: + +```kotlin +dependencies { + // Ktor WebSocket client + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-cio:3.1.1") + implementation("io.ktor:ktor-client-websockets:3.1.1") + + // Quartz (use your local module or published artifact) + implementation(project(":quartz")) +} +``` + +### 2. Implement the Ktor WebSocket Transport + +Quartz uses two interfaces for WebSocket connectivity: + +```kotlin +// WebSocket — a single connection to a relay +interface WebSocket { + fun needsReconnect(): Boolean + fun connect() + fun disconnect() + fun send(msg: String): Boolean +} + +// WebsocketBuilder — factory that creates WebSocket instances +interface WebsocketBuilder { + fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket +} + +// WebSocketListener — callbacks from the transport layer +interface WebSocketListener { + fun onOpen(pingMillis: Int, compression: Boolean) + fun onMessage(text: String) // must deliver messages in order + fun onClosed(code: Int, reason: String) + fun onFailure(t: Throwable, code: Int?, response: String?) +} +``` + +Here's a complete Ktor implementation: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.* + +class KtorWebSocket( + private val url: NormalizedRelayUrl, + private val client: HttpClient, + private val out: WebSocketListener, +) : WebSocket { + private var session: DefaultClientWebSocketSession? = null + private var job: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun needsReconnect() = session == null + + override fun connect() { + job = scope.launch { + try { + client.webSocket(url.url) { + session = this + out.onOpen(0, false) + + try { + for (frame in incoming) { + if (frame is Frame.Text) { + out.onMessage(frame.readText()) + } + } + } finally { + val reason = closeReason.await() + session = null + out.onClosed( + reason?.code?.toInt() ?: 1000, + reason?.message ?: "Connection closed", + ) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + session = null + out.onFailure(e, null, e.message) + } + } + } + + override fun disconnect() { + job?.cancel() + job = null + session = null + } + + override fun send(msg: String): Boolean { + val currentSession = session ?: return false + return try { + runBlocking { currentSession.send(Frame.Text(msg)) } + true + } catch (e: Exception) { + false + } + } + + class Builder( + private val client: HttpClient, + ) : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ) = KtorWebSocket(url, client, out) + } +} +``` + +### 3. Subscribe to Events (Flow API) + +The simplest way to receive events — `subscribeAsFlow` returns a `Flow>` that accumulates events as they arrive: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import kotlinx.coroutines.* + +fun main() = runBlocking { + // 1. Create the Ktor HTTP client with WebSocket support + val httpClient = HttpClient(CIO) { + install(WebSockets) + } + + // 2. Create the NostrClient + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // 3. Subscribe to text notes (kind 1) from a relay + val flow = client.subscribeAsFlow( + relay = "wss://nos.lol", + filter = Filter(kinds = listOf(1), limit = 20), + ) + + // 4. Collect events as they arrive + val job = launch { + flow.collect { events -> + println("Got ${events.size} events so far") + events.forEach { event -> + println(" [${event.pubKey.take(8)}...] ${event.content.take(80)}") + } + } + } + + // Let it run for 10 seconds + delay(10_000) + job.cancel() + + // 5. Clean up + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +### 4. Subscribe to Events (Callback API) + +For more control, use the manual subscription API with `SubscriptionListener`: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.* + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // Define a listener for subscription events + val listener = object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + val label = if (isLive) "LIVE" else "STORED" + println("[$label] ${event.pubKey.take(8)}...: ${event.content.take(80)}") + } + + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + println("--- End of stored events from $relay ---") + } + } + + // Build filters per relay + val filters = mapOf( + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( + Filter(kinds = listOf(1), limit = 50), + ), + ) + + // Subscribe + client.subscribe("my-feed", filters, listener) + + delay(15_000) + + // Unsubscribe and clean up + client.unsubscribe("my-feed") + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +### 5. Publish Events + +Create a key pair, sign an event, and publish it to relays: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.* + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // 1. Create a key pair (generates random keys) + val signer = NostrSignerInternal(KeyPair()) + println("Public key: ${signer.pubKey}") + + // 2. Build and sign a text note (kind 1) + val event = signer.sign(TextNoteEvent.build("Hello from Quartz!")) + println("Event ID: ${event.id}") + + // 3. Publish and wait for relay confirmation + val accepted = client.publishAndConfirm( + event = event, + relayList = setOf( + "wss://nos.lol".normalizeRelayUrl(), + "wss://relay.damus.io".normalizeRelayUrl(), + ), + ) + + println("Published: $accepted") + + // 4. Clean up + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +For detailed per-relay results, use `publishAndConfirmDetailed`: + +```kotlin +val results = client.publishAndConfirmDetailed(event, relayList) +results.forEach { (relay, success) -> + println(" $relay: ${if (success) "accepted" else "rejected"}") +} +``` + +## How It Works + +### Architecture + +``` +┌──────────────────────────────────────────────┐ +│ Your Application │ +│ │ +│ subscribeAsFlow() / subscribe() / publish()│ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ NostrClient │ +│ │ +│ PoolRequests (active subscriptions) │ +│ PoolCounts (count queries) │ +│ PoolEventOutbox (pending publishes) │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ RelayPool │ +│ │ +│ Manages BasicRelayClient per relay URL │ +│ Auto-connects/disconnects as needed │ +│ Tracks connected/available relay state │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ BasicRelayClient (per relay) │ +│ │ +│ Connection lifecycle + reconnection │ +│ NIP-01 message parsing │ +│ Exponential backoff on failures │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ WebsocketBuilder → WebSocket │ +│ │ +│ KtorWebSocket (this guide) │ +│ BasicOkHttpWebSocket (built-in for JVM) │ +└──────────────────────────────────────────────┘ +``` + +### Connection Lifecycle + +1. You call `subscribe()` or `publish()` with relay URLs +2. `NostrClient` tells `RelayPool` which relays are needed +3. `RelayPool` creates a `BasicRelayClient` for each new relay +4. `BasicRelayClient` uses `WebsocketBuilder` to create and open a `WebSocket` +5. On connection, all pending subscriptions and events are synced automatically +6. On disconnection, exponential backoff retries the connection +7. On reconnection, filters are re-sent and events re-delivered + +### Subscription Flow + +``` +client.subscribe("my-feed", filters, listener) + │ + ▼ +NostrClient stores filters in PoolRequests + │ + ▼ +RelayPool connects to needed relays + │ + ▼ +BasicRelayClient sends ["REQ", "my-feed", ...] + │ + ▼ +Relay responds with: + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=false) + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=false) + ["EOSE", "my-feed"] → listener.onEose(relay) + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=true) ← new events +``` + +### NIP-01 Commands + +| Direction | Command | Description | +|-----------|---------|-------------| +| Client → Relay | `["REQ", subId, filter...]` | Open a subscription | +| Client → Relay | `["CLOSE", subId]` | Close a subscription | +| Client → Relay | `["EVENT", event]` | Publish an event | +| Relay → Client | `["EVENT", subId, event]` | Deliver a matching event | +| Relay → Client | `["EOSE", subId]` | End of stored events | +| Relay → Client | `["OK", eventId, success, message]` | Publish acknowledgement | +| Relay → Client | `["NOTICE", message]` | Relay notice | + +## Filters + +Filters define what events a subscription matches. All fields are optional — omitted fields match everything. + +```kotlin +// Latest 20 text notes +Filter(kinds = listOf(1), limit = 20) + +// Metadata for specific authors +Filter( + kinds = listOf(0), + authors = listOf( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + ), +) + +// Events since a timestamp +Filter(kinds = listOf(1), since = 1700000000) + +// Events tagged with a specific pubkey +Filter( + kinds = listOf(1), + tags = mapOf("p" to listOf("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")), +) + +// Full-text search (NIP-50, relay must support it) +Filter(kinds = listOf(1), search = "bitcoin", limit = 10) +``` + +### Filter Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `ids` | `List?` | Match specific event IDs (64-char hex) | +| `authors` | `List?` | Match specific author pubkeys (64-char hex) | +| `kinds` | `List?` | Match event kinds (0=metadata, 1=text note, 3=contacts, etc.) | +| `tags` | `Map>?` | Match events with any of the listed tag values | +| `since` | `Long?` | Events with `created_at` >= this Unix timestamp | +| `until` | `Long?` | Events with `created_at` <= this Unix timestamp | +| `limit` | `Int?` | Maximum number of events to return | +| `search` | `String?` | Full-text search (NIP-50) | + +## Key Management + +```kotlin +// Generate a new random key pair +val keyPair = KeyPair() + +// Import an existing private key (32 bytes) +val keyPair = KeyPair(privKey = hexToByteArray("your-64-char-hex-private-key")) + +// Read-only (public key only, cannot sign) +val keyPair = KeyPair(pubKey = hexToByteArray("your-64-char-hex-public-key")) + +// Create a signer for signing events +val signer = NostrSignerInternal(keyPair) +``` + +## Event Kinds + +Common event kinds you'll work with: + +| Kind | NIP | Class | Description | +|------|-----|-------|-------------| +| 0 | NIP-01 | `MetadataEvent` | User metadata (name, picture, about) | +| 1 | NIP-01 | `TextNoteEvent` | Text note (short post) | +| 3 | NIP-02 | `ContactListEvent` | Contact list / follow list | +| 4 | NIP-04 | `PrivateDmEvent` | Encrypted direct message | +| 7 | NIP-25 | `ReactionEvent` | Reaction (like, emoji) | +| 30023 | NIP-23 | `LongTextNoteEvent` | Long-form content (articles) | + +## Multi-Relay Subscriptions + +Subscribe to different filters on different relays: + +```kotlin +val filters = mapOf( + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( + Filter(kinds = listOf(1), limit = 50), + ), + RelayUrlNormalizer.normalize("wss://relay.damus.io") to listOf( + Filter(kinds = listOf(0, 3), authors = listOf(myPubKey)), + ), +) + +client.subscribe("multi-relay-feed", filters, listener) +``` + +## Using OkHttp Instead of Ktor + +Quartz ships with a built-in OkHttp WebSocket implementation for JVM/Android: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import okhttp3.OkHttpClient + +val httpClient = OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .build() + +val socketBuilder = BasicOkHttpWebSocket.Builder { url -> httpClient } +val client = NostrClient(socketBuilder, scope) +``` + +## Full Example: Simple Feed Reader + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.take + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + println("Connecting to relay...") + + client.subscribeAsFlow( + relay = "wss://nos.lol", + filter = Filter(kinds = listOf(1), limit = 10), + ).take(1).collect { events -> + println("\n=== Latest ${events.size} text notes ===\n") + events.forEach { event -> + println("Author: ${event.pubKey.take(16)}...") + println("Content: ${event.content.take(120)}") + println("---") + } + } + + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +## Key Source Files + +All client infrastructure lives in the `quartz` module: + +``` +quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/ +├── relay/client/ +│ ├── NostrClient.kt # Main client (manages relay pool + subscriptions) +│ ├── INostrClient.kt # Client interface +│ ├── reqs/ +│ │ ├── SubscriptionListener.kt # Callback interface for subscriptions +│ │ └── NostrClientSubscribeAsFlowExt.kt # Flow-based subscription API +│ └── accessories/ +│ └── NostrClientPublishExt.kt # publishAndConfirm / publishAndConfirmDetailed +├── relay/sockets/ +│ ├── WebSocket.kt # Transport interface +│ ├── WebSocketListener.kt # Transport callback interface +│ └── WebsocketBuilder.kt # Transport factory interface +├── relay/filters/ +│ └── Filter.kt # NIP-01 subscription filters +├── crypto/ +│ ├── KeyPair.kt # Schnorr key pair +│ └── Nip01Crypto.kt # Low-level crypto operations +└── signers/ + ├── NostrSigner.kt # Abstract signer + └── NostrSignerInternal.kt # Signs with local private key +```