# Relay transport The relay layer handles everything below the application protocol: WebSocket connectivity, message encryption, reconnection, and message queuing. Application code (wallet, dapp) should never touch NDK or Nostr directly. ## Stack ``` Application messages (hdwalletv1 JSON) ↓ JSON.stringify NDK PrivateDirectMessage (kind 14, rumor) ↓ NIP-17 gift wrap (kind 1059, encrypted to recipient) NDK → Nostr relay (WebSocket) ↓ stored as kind 1059 event, tagged with recipient pubkey NDK ← Nostr relay subscription (filter: kind=1059, #p=) ↓ giftUnwrap → PrivateDirectMessage → JSON.parse Application message handler ``` ## RelayClient `RelayClient` is the low-level building block. One instance per peer connection. ### Responsibilities - Owns an NDK instance configured with explicit relay URLs. - Subscribes to `kind:1059` (GiftWrap) events tagged with its own pubkey. - Decrypts and unwraps incoming events using NIP-17 `giftUnwrap`. - Publishes outbound messages using `giftWrap` addressed to the paired peer. - Filters incoming messages by peer pubkey (rejects messages from unknown senders). - Discards messages older than `lastProcessedTimestamp` (replay protection). - Queues outbound messages until at least one relay is ready. ### Construction ```typescript new RelayClient({ explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.riften.net:443"] signerPrivateKey: Uint8Array; // 32-byte secp256k1 private key (this client's identity) pairedPublicKey?: Uint8Array; // 32-byte x-only pubkey of the peer (set after key exchange) logNetworkActivity?: boolean; // default true }) ``` ### Key methods ```typescript connect(): Promise // NDK connect, subscribe to GiftWrap events, start waiting for relays. disconnect(): Promise // Stop subscription, mark queue not-ready, update lastProcessedTimestamp. relay(message: ProtocolMessage): Promise // Send a message. Enqueues if relays not ready. Throws if paired key not set. setPairedPublicKey(key: Uint8Array): void // Called after key exchange. Enables outbound messages and incoming peer filtering. isKeyExchangeComplete(): boolean // true once pairedPublicKey is set and non-zero. nextSequence(): number // Returns a unique sequence number (starts at random offset, increments by 2). getLastProcessedTimestamp() / setLastProcessedTimestamp(ts): void // Persist/restore across reconnects to avoid re-delivering buffered messages. ``` ### Message queue Before any relay is confirmed as connected, outbound `relay()` calls are enqueued in a `MessageQueue`. The client polls every 100 ms for up to 5 seconds for at least one relay with status 1 (connected). After that timeout it assumes the relay is ready and flushes the queue regardless (avoiding silent message loss on slow connections). On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are held rather than dropped. ### Replay protection `lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated to `now` in `disconnect()`. Any incoming message with `time < lastProcessedTimestamp` is silently dropped. This prevents the relay from re-delivering messages that were already handled before a disconnect. ### Keepalive `RelayClient` creates its `SimplePool` with `enablePing: true`. This enables nostr-tools' built-in heartbeat: every 29 seconds the pool pings each connected relay and expects a response within 20 seconds. In Node.js this uses native WebSocket ping/pong frames; in browsers (where the WebSocket API doesn't expose ping) it falls back to sending a dummy subscription request and waiting for EOSE. If a relay fails to respond, nostr-tools closes the WebSocket, which fires the subscription `onclose` callback, which emits `"disconnect"` on `RelayClient`, which triggers the connection manager's existing reconnect loop. This detects "zombie" TCP connections where the socket appears open but the relay is unreachable. Additionally, if a `publishMessage()` call fails (all relays reject the event), `RelayClient` emits `"disconnect"` alongside the thrown error. This ensures the reconnect loop starts immediately rather than waiting for the next ping cycle. ### Sequence numbers `nextSequence()` starts at a random offset in the safe integer range and increments by 2. This means two instances are unlikely to collide (they start at different offsets), and the step of 2 leaves room for error responses at odd offsets if needed in the future. ## initiateRelay `initiateRelay()` (in `relay-handler.ts`) wraps `RelayClient` with a reconnect loop: ```typescript initiateRelay( statusCallback: RelayStatusCallback, privateKey: Uint8Array, initialPairedKey: Uint8Array, options: { explicitRelayUrls, reconnectInterval?, maxReconnectAttempts? } ): () => void // returns cleanup function ``` It calls `statusCallback` with a `RelayUpdatePayload` on every state change: ```typescript interface RelayUpdatePayload { client: RelayClient; status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" } } ``` The reconnect loop is simple: on a disconnect event from the client it waits `reconnectInterval` (default 5000 ms) and calls `client.connect()` again. No exponential backoff currently — connections are expected to be stable (relay and mobile network) and fast to re-establish. ## initiateDappRelay Higher-level helper that bundles credential generation, URI encoding, key exchange handling, and reconnection into one call: ```typescript initiateDappRelay( statusCallback: RelayStatusCallback, options?: { explicitRelayUrls?: string[]; reconnectInterval?: number; maxReconnectAttempts?: number; existingCredentials?: { privateKey: string; secret: string }; } ): DappRelayResult interface DappRelayResult { client: RelayClient; uri: string; // wiz:// URI to encode into QR credentials: KeyExchangeCredentials; events: EventEmitter<{ keyexchangecomplete: [walletPublicKey: Uint8Array] }>; cleanup: () => void; } ``` Internally it wraps the caller's `statusCallback` to intercept `wallet_ready` messages, verify the secret, and call `client.setPairedPublicKey()` before the message is processed by `DappConnectionManager`. On reconnect, if `walletPublicKeyNostr` is already known it immediately restores the paired key so outbound messages can be sent before the next `wallet_ready` arrives. ## initiateWalletRelay ```typescript initiateWalletRelay( statusCallback: RelayStatusCallback, options: { uri: string; // wiz:// URI from QR scan walletPrivateKey: Uint8Array; // wallet's relay identity key (may be ephemeral) } ): { cleanup: () => void } ``` Decodes the URI, sets the dapp's public key as the initial paired key, and starts the relay. On each connect/reconnect, the wallet sends `wallet_ready` which carries the key exchange fields (`public_key`, `secret`) alongside the session data. This gives the dapp the wallet's pubkey atomically with the application handshake data. ## Encryption: NIP-17 gift wrap NIP-17 gift wrap works like a sealed envelope: 1. **Rumor** (kind 14, `PrivateDirectMessage`): the plaintext message, signed by the sender. Content is the JSON payload. Tags include `["p", recipient_pubkey_hex]`. 2. **Seal** (kind 13): the rumor encrypted with a shared ECDH secret derived from sender's private key and recipient's public key. No `p` tag — unlinkable to sender/recipient. 3. **Wrap** (kind 1059, `GiftWrap`): the seal encrypted to the recipient's public key using a freshly generated throwaway key. Has a `["p", recipient_pubkey_hex]` tag so the relay can route it to the right subscription. The relay sees only kind 1059 with a recipient tag. It cannot read content or link messages to senders. The throwaway outer key means even the relay cannot correlate multiple messages from the same sender. NDK handles all three layers in `giftWrap()` / `giftUnwrap()`. ## Default relays ``` wss://relay.riften.net:443 (primary) wss://relay.cauldron.quest:443 (secondary) ``` Both relays are used by default on both dapp and wallet sides for redundancy. Since Nostr relays do not federate (they don't forward events to each other), connecting to multiple relays ensures messages are delivered even if one relay is temporarily unavailable. The connection URI encodes only the primary relay; the secondary is added programmatically by the library. When a custom relay is specified (via URI or `explicitRelayUrls`), only that relay is used — default relays are not auto-added. Nothing in the protocol prevents using any other standard Nostr relay. ### Duplicate message handling When subscribed to multiple relays, the same event may arrive from more than one relay. nostr-tools `SimplePool.subscribeMany()` deduplicates events by ID — it tracks seen event IDs in a per-subscription `_knownIds` set and only fires `onevent` once per unique ID. Since `pool.publish(urls, event)` sends the identical event (same ID) to all relays, the receiving side's pool delivers it exactly once.