How the Geohash Presence Service Tracks Nearby Users in BitChat

The geohash presence service tracks nearby users by broadcasting ephemeral Nostr Kind 20001 events to low-precision geohash channels every 40–80 seconds, using randomized delays and derived identities to prevent correlation attacks.

The geohash presence service powers location-based discovery in the BitChat messaging app (permissionlesstech/bitchat) while enforcing strict privacy boundaries. As implemented in GeohashPresenceService.swift, the system uses the Nostr protocol to announce user presence in geographic zones without exposing precise coordinates or correlating activity across different precision levels.

Three-Phase Location Tracking Architecture

The service operates through a continuous cycle of detection, scheduling, and broadcasting that maintains an up-to-date view of nearby participants.

Detecting Location Changes

The service subscribes to LocationStateManager.shared.$availableChannels to monitor when the device enters or exits geohash zones. When the set of available channels changes, the service schedules an immediate heartbeat after a 5-second debounce period, ensuring the user is promptly announced in new areas.

Randomized Heartbeat Scheduling

While the app is active and Tor is ready, the service runs a looping timer with intervals randomized between 40 seconds and 80 seconds (loopMinInterval to loopMaxInterval). Each iteration performs the following steps:

  1. Filters current channels to include only precision levels 2 (region), 4 (province), and 5 (city)
  2. Launches independent Task instances for each permitted channel
  3. Each task sleeps for a random 2–5 seconds (burstMinDelay to burstMaxDelay) before broadcasting

This temporal decorrelation prevents observers from linking presence broadcasts across different geohash levels to a single identity.

func scheduleNextHeartbeat() {
    guard started else { return }
    heartbeatTimer?.invalidate()
    let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
    let generation = heartbeatGeneration
    heartbeatTimer = scheduleTimer(interval) { [weak self] in
        Task { @MainActor [weak self] in
            guard let self,
                  self.started,
                  self.heartbeatGeneration == generation else { return }
            self.performHeartbeat()
        }
    }
}

Broadcasting Presence Events

For each allowed geohash, the service derives a short-lived identity via deriveIdentity(forGeohash:) and constructs a Kind 20001 event using NostrProtocol.createGeohashPresenceEvent. The event is sent to the nearest relays determined by relayLookup and transmitted via relaySender.

func broadcastPresence(for geohash: String) {
    do {
        guard let identity = try? deriveIdentity(geohash) else { return }
        let event = try NostrProtocol.createGeohashPresenceEvent(
            geohash: geohash,
            senderIdentity: identity
        )
        let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
        if !targetRelays.isEmpty {
            relaySender(event, targetRelays)
        }
    } catch {
        SecureLogger.error("Presence: failed to create event for \(geohash): \(error)")
    }
}

Privacy Protections in Presence Tracking

The implementation enforces multiple safeguards to prevent location tracking and identity correlation.

Geohash Precision Filtering

The service strictly filters broadcasts using an allowedPrecisions set that excludes high-precision geohashes. Only region (2 characters), province (4 characters), and city (5 characters) levels receive presence announcements. High-precision levels such as neighbourhood, block, and building are never broadcast, ensuring exact locations remain private.

Temporal Decorrelation

To prevent timing analysis attacks, each heartbeat iteration introduces randomized delays. The main loop varies between 40–80 seconds, while individual channel broadcasts add randomized 2–5 second delays before transmission. This "burst" decorrelation ensures that presence events for different geohash levels occur at uncorrelated times.

for channel in channels {
    // privacy restriction – only low‑precision levels are allowed
    if !allowedPrecisions.contains(channel.geohash.count) { continue }

    let taskID = UUID()
    let delay = TimeInterval.random(in: burstMinDelay...burstMaxDelay)
    let nanoseconds = UInt64(delay * 1_000_000_000)

    let task = Task { @MainActor [weak self] in
        await sleeper(nanoseconds)            // random 2‑5 s delay
        guard let self,
              !Task.isCancelled,
              self.started,
              self.heartbeatGeneration == generation else { return }
        self.broadcastPresence(for: channel.geohash)
    }
    pendingBroadcastTasks[taskID] = task
}

Counting Online Participants

Incoming events are processed by GeohashMessageHandler, which maintains a mapping of pubkey → lastSeenTimestamp. A participant is considered online if their last seen timestamp is within the last 5 minutes. The handler processes both Kind 20000 chat messages and Kind 20001 presence heartbeats to update this state continuously.

The UI layer displays exact counts for low-precision channels where presence is actively broadcast, while showing "[? people]" for high-precision channels where privacy restrictions prevent presence announcements.

Summary

  • The geohash presence service uses ephemeral Nostr Kind 20001 events to announce user presence in low-precision geohash zones.
  • Heartbeats run on randomized 40–80 second intervals with additional 2–5 second decorrelation delays to prevent identity correlation.
  • Only region, province, and city level geohashes (precision 2, 4, and 5) receive broadcasts; neighbourhood, block, and building locations remain private.
  • GeohashMessageHandler counts participants as online when seen within the last 5 minutes, updating the UI accordingly.
  • All broadcast logic resides in GeohashPresenceService.swift with specifications documented in GeohashPresenceSpec.md.

Frequently Asked Questions

What prevents the geohash presence service from leaking exact locations?

The service filters broadcasts using an allowedPrecisions set that excludes high-precision geohashes. Only region (2 characters), province (4 characters), and city (5 characters) levels receive presence events, while neighbourhood, block, and building coordinates remain unannounced according to the privacy rules defined in GeohashPresenceSpec.md.

How does the service prevent timing correlation attacks?

Each heartbeat iteration introduces randomization at two levels: the main loop varies between 40–80 seconds (loopMinInterval to loopMaxInterval), and individual channel broadcasts add randomized 2–5 second delays (burstMinDelay to burstMaxDelay). This temporal decorrelation makes it difficult to link multiple presence events to a single identity.

Which Nostr event kinds does the presence system use?

The system broadcasts Kind 20001 (GEOHASH_PRESENCE) events for presence announcements and listens for both Kind 20001 and Kind 20000 (GEO_CHAT) events to determine if a user is online within the last 5 minutes.

Where is the presence logic implemented in the BitChat codebase?

The core implementation lives in GeohashPresenceService.swift, which handles scheduling, identity derivation via deriveIdentity(forGeohash:), and broadcasting. Event processing and user counting occur in GeohashMessageHandler.swift, with design specifications detailed in GeohashPresenceSpec.md.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →