# How BitChat Clients Select and Manage Nostr Relays: Architecture Deep Dive

> Explore how BitChat clients select and manage Nostr relays. Discover the three-stage architecture including whitelists custom relays and a dynamic policy engine for privacy and permissions.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: deep-dive
- Published: 2026-08-05

---

**BitChat selects and manages Nostr relays through a three-stage architecture combining built-in whitelists, user-defined custom relays with strict validation, and a runtime policy engine that dynamically adjusts the active relay set based on privacy context and user permissions.**

The open-source BitChat iOS application implements a sophisticated relay management system in Swift that balances decentralization, privacy, and reliability. According to the [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat) source code, the client never blindly connects to arbitrary relays—instead, it enforces a curated, policy-driven approach governed by `NostrRelayManager` and `NostrRelaySettings`.

---

## Relay Discovery and Normalization Pipeline

Every relay URL undergoes a strict validation and normalization process before it can be used for connections.

### Built-In Relay Whitelist

BitChat ships with a hard-coded set of trusted relays known to support private-message envelopes. These **built-in relays** serve as the default fallback when user context permits.

In [`bitchat/Nostr/NostrRelayManager.swift`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/NostrRelayManager.swift), the built-in list is defined as a static property:

```swift
// NostrRelayManager.swift
static let builtInRelayURLs: [String] = [
    "wss://relay.damus.io",
    "wss://nos.lol",
    "wss://offchain.pub"
]

```

### User-Defined Custom Relays

Users can augment the default set with up to **5 custom relays**, persisted in `UserDefaults`. The `NostrRelaySettings` enum in [`bitchat/Nostr/NostrRelaySettings.swift`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/NostrRelaySettings.swift) enforces this limit and manages all persistence:

```swift
// NostrRelaySettings.swift
enum NostrRelaySettings {
    static let didChangeNotification = Notification.Name("NostrRelaySettingsDidChange")
    static let maxCustomRelays = 5
    
    static func customRelays(in defaults: UserDefaults = .standard) -> [String] {
        defaults.stringArray(forKey: "customNostrRelays") ?? []
    }
}

```

### URL Normalization and Validation

Before any URL is stored or connected to, `NostrRelayURL.normalized(_:)` performs sanitization:

- Validates the `wss://` scheme (WebSocket Secure)
- Strips trailing slashes
- Lower-cases the host component
- Returns a canonical string or `nil` for invalid input

```swift
// NostrRelayURL.swift (utility extension)
extension String {
    var normalized: String? {
        guard let url = URL(string: self),
              url.scheme?.lowercased() == "wss",
              let host = url.host?.lowercased() else {
            return nil
        }
        return "wss://\(host)"
    }
}

```

The UI layer deduplicates after normalization and explicitly **rejects attempts to add built-in relays** to the custom list, preventing redundant configuration.

---

## Runtime Relay Policy Engine

The most distinctive feature of BitChat's relay management is its **context-aware policy system** that dynamically enables or disables the built-in relay set based on three runtime signals.

### Policy Evaluation Logic

In `NostrRelayManager.applyDefaultRelayPolicy(force:)`, the manager checks:

1. **Mutual favorites presence** — Does the user have established friend connections?
2. **Location permission granted** — Has the user authorized location access?
3. **Active location channel** — Is the user currently inside a geofenced chat region?

```swift
// NostrRelayManager.swift (simplified excerpt)
func applyDefaultRelayPolicy(force: Bool = false) {
    let hasMutualFavorites = !friendService.mutualFavorites.isEmpty
    let hasLocationPermission = locationManager.authorizationStatus == .authorizedAlways
    let inLocationChannel = currentChannel?.type == .location
    
    let shouldAllowDefaults = hasMutualFavorites || hasLocationPermission || inLocationChannel
    
    if shouldAllowDefaults != allowDefaultRelays || force {
        allowDefaultRelays = shouldAllowDefaults
        // Trigger relay set recalculation and reconnection
        updateActiveRelaySet()
    }
}

```

### Dynamic Relay Set Updates

When `allowDefaultRelays` toggles:

| Transition | Action |
|------------|--------|
| **Enabled** | Built-in relays merge with custom list; `ensureConnections(to:)` opens new connections |
| **Disabled** | `dropRelays(_:)` closes built-in connections; only custom relays remain active |

This privacy-preserving design ensures that location tracking never occurs without explicit user context justifying broader relay exposure.

---

## Connection Lifecycle and Maintenance

`NostrRelayManager` orchestrates WebSocket connections with enterprise-grade reliability patterns.

### Lazy Connection Establishment

Connections open on-demand via `ensureConnections(to:)`:

```swift
// NostrRelayManager.swift
func ensureConnections(to urls: [String]? = nil) {
    let targetURLs = urls ?? allowedRelayList(from: combinedRelaySources)
    let filtered = targetURLs.filter { !activeConnections.keys.contains($0) }
    connectToRelays(filtered, shouldLog: true)
}

```

The `allowedRelayList(from:)` method deduplicates, normalizes, and filters based on current policy:

```swift
private func allowedRelayList(from candidates: [String]) -> [String] {
    let normalized = candidates.compactMap { $0.normalized }
    let unique = Array(Set(normalized))
    
    if !allowDefaultRelays {
        return unique.filter { !Self.builtInRelayURLs.contains($0) }
    }
    return unique
}

```

### Tor Integration and Connection Queuing

For Tor-enabled configurations, connections are deferred until the Tor circuit is ready:

```swift
private var pendingTorConnectionURLs: [String] = []

func queueConnectionsUntilTorReady(_ urls: [String]) {
    pendingTorConnectionURLs.append(contentsOf: urls)
}

func torDidBecomeReady() {
    let pending = pendingTorConnectionURLs
    pendingTorConnectionURLs.removeAll()
    ensureConnections(to: pending)
}

```

### Exponential Backoff and Retry Limits

Each relay connection implements jittered exponential backoff:

```swift
// TransportConfig.swift
struct TransportConfig {
    static let nostrRelayInitialBackoffSeconds: TimeInterval = 2
    static let nostrRelayMaxBackoffSeconds: TimeInterval = 300  // 5 minutes
    static let nostrRelayBackoffMultiplier = 2.0
    static let nostrRelayMaxReconnectAttempts = 10
}

```

Failed connections schedule retry with `scheduleAfter(_:jitterUnit:)` until `maxReconnectAttempts` exhausts or manual reconnect resets the counter.

### Inbound Message Pipeline

Per-relay, `ensureRelayInboundPipeline` sets up:

- **Frame size limits** (`nostrInboundMaxFrameBytes` prevents memory exhaustion)
- **Event deduplication** (SHA-256 ID cache avoids replay attacks)
- **Subscription state tracking** (pending REQ/EOSE correlation)

```swift
private func ensureRelayInboundPipeline(for url: String, on adapter: WebSocketTaskAdapter) {
    adapter.receive { [weak self] result in
        switch result {
        case .success(let data):
            self?.processInboundFrame(data, from: url)
        case .failure(let error):
            self?.handleDisconnection(url, error: error)
        }
    }
}

```

---

## Typical Client Usage Patterns

Developers integrating with `NostrRelayManager` use these patterns:

```swift
import Foundation

// MARK: - Custom Relay Management

// Add a user-defined relay (returns normalized URL or error)
switch NostrRelaySettings.add("relay.example.com", 
                               builtIn: NostrRelayManager.builtInRelayURLs,
                               in: .standard) {
case .success(let normalizedURL):
    print("Added: \(normalizedURL)")
case .failure(let error):
    print("Validation failed: \(error)")
}

// MARK: - Connection Control

// Force reconnection after network change
NostrRelayManager.shared.disconnect()
NostrRelayManager.shared.connect()

// MARK: - Event Publishing

let event = NostrEvent(
    kind: 20000,  // Private message envelope
    content: encryptedPayload,
    tags: [["p", recipientPubkey]],
    createdAt: Date()
)
NostrRelayManager.shared.sendEvent(event)

// MARK: - Subscription with Filter

let filter = NostrFilter(
    kinds: [20000],
    authors: [myPubkey],
    since: Date().addingTimeInterval(-86400),
    until: nil
)

let subId = NostrRelayManager.shared.subscribe(
    filter: filter,
    relayUrls: nil,  // Use policy-determined set
    handler: { event in
        processDecryptedMessage(event)
    },
    onEOSE: {
        print("Historical messages loaded")
    }
)

```

---

## Summary

- **Validation-first URL handling** — `NostrRelayURL.normalized` ensures only well-formed `wss://` URLs enter the system
- **Strict custom relay limits** — `NostrRelaySettings.maxCustomRelays = 5` caps user-defined infrastructure
- **Policy-driven relay selection** — Built-in relays activate only with mutual favorites, location permission, or active geofenced channel
- **Resilient connection management** — Exponential backoff, Tor queuing, and frame-size limits protect against misbehaving relays
- **Observable state changes** — `didChangeNotification` enables reactive UI updates when relay configuration shifts

---

## Frequently Asked Questions

### How many custom relays can a BitChat user add?

BitChat enforces a **maximum of 5 custom relays** per user account, defined by `NostrRelaySettings.maxCustomRelays` in [`bitchat/Nostr/NostrRelaySettings.swift`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/NostrRelaySettings.swift). This limit prevents configuration complexity and reduces attack surface from untrusted relay operators.

### Why do built-in relays sometimes disappear from my connection list?

The `applyDefaultRelayPolicy()` method dynamically removes built-in relays when none of three conditions are met: (1) zero mutual favorite contacts, (2) revoked location permission, and (3) exit from all location-based channels. This privacy mechanism ensures broader network exposure only occurs with explicit user context.

### What happens if a custom relay fails to connect?

Failed connections trigger exponential backoff starting at 2 seconds, doubling up to a 5-minute maximum, with a hard limit of 10 reconnection attempts. The manager retains subscription state and resumes pending `REQ` messages upon successful reconnection or user-initiated manual reconnect.

### Can BitChat operate with only custom relays and no built-ins?

Yes. When `allowDefaultRelays` is disabled by policy evaluation, `allowedRelayList(from:)` filters out all built-in URLs. Users maintain full functionality provided their custom relays support the required NIP-46 or encrypted DM message kinds that BitChat depends upon.