How BitChat Clients Select and Manage Nostr Relays: Architecture Deep Dive
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 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, the built-in list is defined as a static property:
// 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 enforces this limit and manages all persistence:
// 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
nilfor invalid input
// 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:
- Mutual favorites presence — Does the user have established friend connections?
- Location permission granted — Has the user authorized location access?
- Active location channel — Is the user currently inside a geofenced chat region?
// 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:):
// 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:
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:
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:
// 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 (
nostrInboundMaxFrameBytesprevents memory exhaustion) - Event deduplication (SHA-256 ID cache avoids replay attacks)
- Subscription state tracking (pending REQ/EOSE correlation)
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:
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.normalizedensures only well-formedwss://URLs enter the system - Strict custom relay limits —
NostrRelaySettings.maxCustomRelays = 5caps 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 —
didChangeNotificationenables 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. 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →