mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Gateway mode: opt-in mesh↔Nostr uplink for geohash channels (#1384)
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Gateway mode: opt-in mesh↔Nostr uplink for geohash channels An opt-in "internet gateway" toggle lets one connected phone bridge the local geohash channel for mesh-only peers: signed kind-20000 events ride a new nostrCarrier (0x28) packet — directed to the gateway for uplink, broadcast with TTL for downlink — with Schnorr verification at every hop, CourierStore-style quotas, and explicit loop-prevention rules. - BitFoundation: MessageType.nostrCarrier = 0x28 - NostrCarrierPacket: 2-byte-length TLV codec (direction, geohash, signed event JSON), 16 KiB cap, tolerant decoder - GatewayService: closure-injected policy layer — verify gates (sig, kind, #g tag, age, size), uplink quotas (10/min/depositor rate limit, offline queue of 20 total / 5 per depositor, drop-oldest, flush on reconnect), downlink budget (30/min, bounded drop-oldest backlog), bounded loop-prevention ID sets - BLEService: runtime capability bits (advertise .gateway only while the toggle is on, re-announce on change), signed directed uplink sends, carrier ingress with depositor signature verification - Mesh-only senders uplink automatically from sendGeohash when no relay is connected and a reachable peer advertises .gateway; once-per- channel "sent via mesh gateway" notice - UI: gateway toggle beside the Tor toggle, globe header indicator, VoiceOver labels, xcstrings entries Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Gateway: harden downlink freshness, uplink verify ordering, and drain Fixes the confirmed downlink/uplink defects from the PR #1384 review + Codex findings: - Downlink age + #g gate (Codex P2 / review #1): rebroadcastRelayEvent now drops events outside the same freshness window receivers enforce and whose #g tag mismatches the carrier geohash, BEFORE spending any budget — so a 1h/200-event channel-resubscribe backfill no longer burns the 30/min BLE budget on events every receiver drops. - Rate-limit + dedup before Schnorr (review #2): handleUplinkDeposit now runs cheap structural checks + carried-ID dedup + rate-token consume before isValidSignature(), so a replay flood is bounded by cheap work instead of unbounded main-actor verifies. - Quota-dropped deposits not rendered (review #3): enqueueUplink reports acceptance and injectInbound only fires for events actually published/queued, ending the local-timeline divergence. - Drain timer + mark-after-send (Codex P2 / review #4): a burst beyond budget now arms a timer to drain when the window frees; rebroadcast IDs are marked only after an event is actually sent, so overflow- dropped events stay retryable. - Symmetric publish path (review #5): the gateway publish closure now refuses when no geo relay is known, matching the local send path instead of publishing dead traffic to default relays. - Loop-rule doc (review #7): softened to reflect that rule 3 is a call-site convention with unit-tested backstops; added tests for the publishedEventIDs backstop, downlink freshness/mismatch, drain timer, and quota-drop non-injection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Gateway: stop self-echo of uplinked events onto the mesh Every event a gateway uplinks to the relays comes back through its own geohash subscription. `rebroadcastRelayEvent` deduped against `meshBroadcastEventIDs`, `rebroadcastEventIDs`, and `pendingDownlinks`, but not `publishedEventIDs` — so an event this gateway just published was downlink-rebroadcast onto the same mesh it originated from, doubling BLE airtime per uplinked message and able to starve the 30/min downlink budget on a busy channel (device-confirmed, filed on #1384). Fix: also skip the downlink rebroadcast when the event id is in `publishedEventIDs`. That set is already the bounded (drop-oldest, capacity maxTrackedEventIDs) loop-rule-2 uplink cache, populated only by `publish()`, so genuine inbound-from-internet events (never published here) still rebroadcast normally. Reconciles cleanly with the existing loop-prevention sets — no new state. Adds a GatewayServiceTests case asserting an uplinked event that echoes back via the subscription is not rebroadcast, while a genuine inbound event still is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
2360140760
commit
d4f0c49787
@@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject {
|
||||
@Published private(set) var bookmarkNames: [String: String]
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||
@Published private(set) var userTorEnabled: Bool
|
||||
@Published private(set) var gatewayEnabled: Bool
|
||||
|
||||
private let manager: LocationChannelManager
|
||||
private let network: NetworkActivationService
|
||||
private let gateway: GatewayService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
manager: LocationChannelManager? = nil,
|
||||
network: NetworkActivationService? = nil
|
||||
network: NetworkActivationService? = nil,
|
||||
gateway: GatewayService? = nil
|
||||
) {
|
||||
let manager = manager ?? .shared
|
||||
let network = network ?? .shared
|
||||
let gateway = gateway ?? .shared
|
||||
|
||||
self.manager = manager
|
||||
self.network = network
|
||||
self.gateway = gateway
|
||||
self.gatewayEnabled = gateway.isEnabled
|
||||
self.permissionState = manager.permissionState
|
||||
self.availableChannels = manager.availableChannels
|
||||
self.selectedChannel = manager.selectedChannel
|
||||
@@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject {
|
||||
network.setUserTorEnabled(enabled)
|
||||
}
|
||||
|
||||
func setGatewayEnabled(_ enabled: Bool) {
|
||||
gateway.setEnabled(enabled)
|
||||
}
|
||||
|
||||
func refreshMeshChannelsIfNeeded() {
|
||||
guard case .mesh = selectedChannel,
|
||||
permissionState == .authorized,
|
||||
@@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject {
|
||||
network.$userTorEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$userTorEnabled)
|
||||
|
||||
gateway.$isEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$gatewayEnabled)
|
||||
}
|
||||
|
||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||
|
||||
@@ -9348,6 +9348,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.accessibility.gateway_active" : {
|
||||
"comment" : "Accessibility label for the internet gateway indicator",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Internet gateway active, sharing your connection with the mesh"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.accessibility.jump_to_latest" : {
|
||||
"comment" : "Accessibility label for the jump to latest messages button",
|
||||
"extractionState" : "manual",
|
||||
@@ -17884,6 +17896,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.header.gateway_active" : {
|
||||
"comment" : "Tooltip for the internet gateway indicator",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sharing your internet connection with nearby mesh peers"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.header.people" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -25819,6 +25843,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_channels.gateway.subtitle" : {
|
||||
"comment" : "Explanation under the internet gateway toggle",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "share your internet with nearby mesh peers so their geohash messages reach the network"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_channels.gateway.title" : {
|
||||
"comment" : "Title for the internet gateway toggle in the location channels sheet",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "internet gateway"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_channels.loading_nearby" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -33665,6 +33713,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.gateway.sent_via_mesh" : {
|
||||
"comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "sent via mesh gateway"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.geohash.blocked" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// NostrCarrierPacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
|
||||
/// Nostr event ferried over the mesh between a mesh-only peer and an
|
||||
/// internet gateway peer.
|
||||
///
|
||||
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
|
||||
/// a mesh-only sender asks the gateway to publish its locally signed
|
||||
/// geohash event to Nostr relays.
|
||||
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
|
||||
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
|
||||
///
|
||||
/// The carried event is public geohash chat — already plaintext on Nostr —
|
||||
/// so the carrier adds no encryption. It IS signed by the originator's
|
||||
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
|
||||
/// or alter it undetected: gateways and receivers verify the Schnorr
|
||||
/// signature before acting on it.
|
||||
///
|
||||
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
|
||||
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
|
||||
/// for forward compatibility.
|
||||
struct NostrCarrierPacket: Equatable {
|
||||
enum Direction: UInt8 {
|
||||
case toGateway = 0x01
|
||||
case fromGateway = 0x02
|
||||
}
|
||||
|
||||
let direction: Direction
|
||||
let geohash: String
|
||||
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
|
||||
/// content, sig).
|
||||
let eventJSON: Data
|
||||
|
||||
/// BLE airtime cap for a carried event.
|
||||
static let maxEventJSONBytes = 16 * 1024
|
||||
static let maxGeohashLength = 12
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case direction = 0x01
|
||||
case geohash = 0x02
|
||||
case eventJSON = 0x03
|
||||
}
|
||||
|
||||
init?(direction: Direction, geohash: String, eventJSON: Data) {
|
||||
let geohashBytes = Data(geohash.utf8)
|
||||
guard !geohashBytes.isEmpty,
|
||||
geohashBytes.count <= Self.maxGeohashLength,
|
||||
!eventJSON.isEmpty,
|
||||
eventJSON.count <= Self.maxEventJSONBytes else {
|
||||
return nil
|
||||
}
|
||||
self.direction = direction
|
||||
self.geohash = geohash
|
||||
self.eventJSON = eventJSON
|
||||
}
|
||||
|
||||
init?(direction: Direction, geohash: String, event: NostrEvent) {
|
||||
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
|
||||
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
|
||||
}
|
||||
|
||||
/// Decodes the carried event. Callers MUST still verify
|
||||
/// `event.isValidSignature()` before publishing or displaying it.
|
||||
func event() -> NostrEvent? {
|
||||
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return try? NostrEvent(from: dict)
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
|
||||
|
||||
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||
data.append(type.rawValue)
|
||||
data.append(UInt8((value.count >> 8) & 0xFF))
|
||||
data.append(UInt8(value.count & 0xFF))
|
||||
data.append(value)
|
||||
}
|
||||
|
||||
appendTLV(.direction, Data([direction.rawValue]))
|
||||
appendTLV(.geohash, Data(geohash.utf8))
|
||||
appendTLV(.eventJSON, eventJSON)
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> NostrCarrierPacket? {
|
||||
// Defensive slice re-base (Data slices keep parent indices).
|
||||
let data = Data(data)
|
||||
var offset = 0
|
||||
var direction: Direction?
|
||||
var geohash: String?
|
||||
var eventJSON: Data?
|
||||
|
||||
while offset + 3 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
|
||||
offset += 3
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = data.subdata(in: offset..<offset + length)
|
||||
offset += length
|
||||
|
||||
switch TLVType(rawValue: typeRaw) {
|
||||
case .direction:
|
||||
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
|
||||
direction = parsed
|
||||
case .geohash:
|
||||
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
|
||||
geohash = parsed
|
||||
case .eventJSON:
|
||||
eventJSON = value
|
||||
case nil:
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility).
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard offset == data.count,
|
||||
let direction,
|
||||
let geohash,
|
||||
let eventJSON else {
|
||||
return nil
|
||||
}
|
||||
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .nostrCarrier:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,11 @@ struct BLEPeerRegistry {
|
||||
peers[peerID.toShort()]?.capabilities ?? []
|
||||
}
|
||||
|
||||
/// Peers whose last verified announce advertised the given capability.
|
||||
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||
}
|
||||
|
||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||
let connected = peers.filter { $0.value.isConnected }
|
||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||
|
||||
@@ -51,8 +51,11 @@ struct BLEReceivePipeline {
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
|
||||
@@ -62,6 +62,14 @@ final class BLEService: NSObject {
|
||||
// Local-only store-and-forward counters; nil in unit tests.
|
||||
var sfMetrics: StoreAndForwardMetrics?
|
||||
|
||||
// Gateway mode: sink for received nostrCarrier packets (set by app
|
||||
// wiring, called on the main actor after transport-level checks) and the
|
||||
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
|
||||
// for every announce. `directedToUs` distinguishes an uplink deposit
|
||||
// addressed to this device from a downlink broadcast.
|
||||
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
|
||||
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
|
||||
|
||||
#if DEBUG
|
||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||
// packets between in-process service instances.
|
||||
@@ -638,6 +646,32 @@ final class BLEService: NSObject {
|
||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||
}
|
||||
|
||||
/// Enables or disables a runtime-advertised capability bit (e.g. the
|
||||
/// internet-gateway toggle) and re-announces so peers learn promptly.
|
||||
/// Build-time bits stay in `PeerCapabilities.localSupported`.
|
||||
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) {
|
||||
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
|
||||
let before = runtimeCapabilities
|
||||
if enabled {
|
||||
runtimeCapabilities.insert(capability)
|
||||
} else {
|
||||
runtimeCapabilities.remove(capability)
|
||||
}
|
||||
return runtimeCapabilities != before
|
||||
}
|
||||
guard changed else { return }
|
||||
sendAnnounce(forceSend: true)
|
||||
}
|
||||
|
||||
/// Reachable peers currently advertising the `.gateway` capability.
|
||||
func reachableGatewayPeers() -> [PeerID] {
|
||||
let now = Date()
|
||||
return collectionsQueue.sync {
|
||||
peerRegistry.peers(advertising: .gateway)
|
||||
.filter { peerRegistry.isReachable($0, now: now) }
|
||||
}
|
||||
}
|
||||
|
||||
func getPeerNicknames() -> [PeerID: String] {
|
||||
return collectionsQueue.sync {
|
||||
peerRegistry.displayNicknames(selfNickname: myNickname)
|
||||
@@ -1272,16 +1306,16 @@ final class BLEService: NSObject {
|
||||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
||||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
||||
|
||||
let connectedPeerIDs: [Data] = collectionsQueue.sync {
|
||||
peerRegistry.connectedRoutingData
|
||||
let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync {
|
||||
(peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities))
|
||||
}
|
||||
|
||||
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: myNickname,
|
||||
noisePublicKey: noisePub,
|
||||
signingPublicKey: signingPub,
|
||||
directNeighbors: connectedPeerIDs,
|
||||
capabilities: PeerCapabilities.localSupported
|
||||
capabilities: advertisedCapabilities
|
||||
)
|
||||
|
||||
guard let payload = announcement.encode() else {
|
||||
@@ -2762,6 +2796,81 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Gateway carrier (nostrCarrier)
|
||||
|
||||
/// Sign and send an encoded `toGateway` carrier payload directed at a
|
||||
/// gateway peer. The packet is signed so the gateway can key its uplink
|
||||
/// quotas to an authenticated depositor; the carried Nostr event has its
|
||||
/// own Schnorr signature for content authenticity. Returns false when
|
||||
/// the gateway is not reachable or signing fails.
|
||||
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool {
|
||||
guard isPeerReachable(gatewayPeer) else { return false }
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.nostrCarrier.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: gatewayPeer.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
guard let signed = noiseService.signPacket(packet) else { return false }
|
||||
messageQueue.async { [weak self] in
|
||||
// broadcastPacket applies a known route when one exists and
|
||||
// otherwise floods the directed packet like a DM, so a gateway
|
||||
// that is reachable but multi-hop still gets the deposit.
|
||||
self?.broadcastPacket(signed)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Broadcast an encoded `fromGateway` carrier payload on the mesh with
|
||||
/// the default TTL. Unsigned at the packet layer — receivers verify the
|
||||
/// carried event's own Schnorr signature.
|
||||
func broadcastNostrCarrier(_ payload: Data) {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.nostrCarrier.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
messageQueue.async { [weak self] in
|
||||
self?.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-level handling for a received nostrCarrier packet; policy
|
||||
/// (verification of the carried event, quotas, loop prevention) lives in
|
||||
/// `GatewayService` behind `onNostrCarrierPacket`.
|
||||
private func handleNostrCarrier(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let senderID = PeerID(hexData: packet.senderID)
|
||||
let directedToUs: Bool
|
||||
if let recipientID = packet.recipientID {
|
||||
// Carriers addressed elsewhere ride the generic relay path untouched.
|
||||
guard recipientID == myPeerIDData else { return }
|
||||
// Uplink deposit: quotas are keyed by the depositor, so the
|
||||
// packet signature must verify against the sender's announced
|
||||
// signing key. Unlike courier deposits the depositor may be
|
||||
// multi-hop away, so ingress-link identity is not required.
|
||||
let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey }
|
||||
guard let signingKey,
|
||||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||
SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security)
|
||||
return
|
||||
}
|
||||
directedToUs = true
|
||||
} else {
|
||||
directedToUs = false
|
||||
}
|
||||
let payload = packet.payload
|
||||
notifyUI { [weak self] in
|
||||
self?.onNostrCarrierPacket?(payload, senderID, directedToUs)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
||||
|
||||
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
||||
@@ -3269,6 +3378,8 @@ extension BLEService {
|
||||
case .boardPost:
|
||||
// Invalid or deleted posts must not spread; skip the relay step.
|
||||
guard handleBoardPost(packet, from: senderID) else { return }
|
||||
case .nostrCarrier:
|
||||
handleNostrCarrier(packet, from: peerID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//
|
||||
// GatewayService.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Policy engine for gateway mode: an opt-in "share my internet with the
|
||||
/// mesh" bridge. While the toggle is on, this device advertises the
|
||||
/// `.gateway` capability bit, publishes signed geohash events deposited by
|
||||
/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay
|
||||
/// events onto the mesh (downlink) so mesh-only peers can take part in the
|
||||
/// local geohash channel. Mesh-only peers need no toggle: their uplink
|
||||
/// engages automatically when relays are unreachable and a gateway peer
|
||||
/// exists.
|
||||
///
|
||||
/// Threat model:
|
||||
/// - Keys never leave the originating device. Mesh-only senders sign events
|
||||
/// locally with their per-geohash ephemeral identity; the gateway carries
|
||||
/// only the finished, signed event.
|
||||
/// - The gateway cannot forge or alter events: every carried event is
|
||||
/// Schnorr-verified here before it is published or rebroadcast, and again
|
||||
/// independently by relays and receivers.
|
||||
/// - Carried contents are public geohash chat, already plaintext on Nostr,
|
||||
/// so the mesh carrier adds no confidentiality loss.
|
||||
///
|
||||
/// Loop-prevention rules:
|
||||
/// 1. An event learned from a `fromGateway` mesh broadcast is never
|
||||
/// re-published to relays, never re-uplinked, and never rebroadcast
|
||||
/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot
|
||||
/// echo mesh-carried traffic back out. Mesh-level propagation of the
|
||||
/// original broadcast packet is the TTL relay's job, not ours.
|
||||
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
|
||||
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
|
||||
/// repeat deposits and relay echoes are absorbed. An event this gateway
|
||||
/// itself uplinked (`publishedEventIDs`) is additionally never
|
||||
/// downlink-rebroadcast: it originated on this mesh, so echoing it back
|
||||
/// when our own relay subscription redelivers it would double BLE airtime
|
||||
/// (the device-confirmed self-echo bug).
|
||||
/// 3. Uplink is only attempted for locally composed events at the send site
|
||||
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
|
||||
/// carrier never re-enter the uplink path. This is a call-site convention;
|
||||
/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in
|
||||
/// `uplinkViaMesh` enforce it defensively and are unit-tested.
|
||||
/// Rules 1 and 2 are enforced here and unit-tested.
|
||||
/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE
|
||||
/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events
|
||||
/// against their own relay subscriptions via the Nostr event-ID cache in
|
||||
/// `NostrInboundPipeline`.
|
||||
///
|
||||
/// All dependencies are closure-injected (repo convention) so the policy
|
||||
/// layer is unit-testable without relays or radios.
|
||||
@MainActor
|
||||
final class GatewayService: ObservableObject {
|
||||
enum Limits {
|
||||
/// Uplink deposits held while relays are unreachable (CourierStore-style
|
||||
/// bounded mailbag: bounded total, bounded per depositor).
|
||||
static let maxQueuedUplinks = 20
|
||||
static let maxQueuedUplinksPerDepositor = 5
|
||||
/// Uplink deposits accepted per depositor per minute.
|
||||
static let uplinkEventsPerMinutePerDepositor = 10
|
||||
/// Downlink mesh rebroadcasts per minute — BLE airtime is precious.
|
||||
/// Beyond the budget events queue (bounded, drop-oldest) and drain on
|
||||
/// a scheduled timer once the window frees (also re-driven by the next
|
||||
/// inbound relay event); a quiet channel does not strand its backlog.
|
||||
static let downlinkEventsPerMinute = 30
|
||||
static let maxPendingDownlinks = 30
|
||||
/// Accepted clock skew for a carried ephemeral event; anything older
|
||||
/// is stale replay the relays would drop anyway.
|
||||
static let maxEventAgeSeconds: TimeInterval = 15 * 60
|
||||
/// Bounded loop-prevention ID caches (oldest evicted).
|
||||
static let maxTrackedEventIDs = 512
|
||||
}
|
||||
|
||||
struct QueuedUplink {
|
||||
let depositor: PeerID
|
||||
let geohash: String
|
||||
let event: NostrEvent
|
||||
let queuedAt: Date
|
||||
}
|
||||
|
||||
static let shared = GatewayService()
|
||||
|
||||
/// The user toggle. While true this device advertises `.gateway` and
|
||||
/// bridges mesh <-> Nostr for geohash channels.
|
||||
@Published private(set) var isEnabled: Bool
|
||||
|
||||
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
|
||||
|
||||
/// Publishes a verified event to the geo relays for a geohash.
|
||||
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
|
||||
/// Broadcasts an encoded `fromGateway` carrier payload on the mesh.
|
||||
var broadcastToMesh: (@MainActor (Data) -> Void)?
|
||||
/// Sends an encoded `toGateway` carrier payload directed to a gateway
|
||||
/// peer. Returns false when the transport could not accept it.
|
||||
var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)?
|
||||
/// Reachable mesh peers currently advertising the `.gateway` capability.
|
||||
var availableGatewayPeers: (@MainActor () -> [PeerID])?
|
||||
/// Whether any Nostr relay connection is currently working.
|
||||
var relaysConnected: (@MainActor () -> Bool)?
|
||||
/// The geohash channel the local user is viewing, if any.
|
||||
var currentGeohash: (@MainActor () -> String?)?
|
||||
/// Injects a verified carried event into the same inbound pipeline as
|
||||
/// relay-received events (blocking, rate limits, dedup, rendering).
|
||||
var injectInbound: (@MainActor (NostrEvent) -> Void)?
|
||||
/// Fired on toggle changes (advertise/withdraw the capability bit and
|
||||
/// force a re-announce).
|
||||
var onEnabledChanged: (@MainActor (Bool) -> Void)?
|
||||
/// Schedules a downlink-drain closure to run after a delay. Injected so
|
||||
/// the drain timer is deterministic in tests; nil arms a real `Task`.
|
||||
var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts.
|
||||
private var meshBroadcastEventIDs: BoundedIDSet
|
||||
/// Loop rule 2 (uplink): event IDs this gateway already published.
|
||||
private var publishedEventIDs: BoundedIDSet
|
||||
/// Loop rule 2 (downlink): event IDs this gateway already rebroadcast.
|
||||
private var rebroadcastEventIDs: BoundedIDSet
|
||||
|
||||
private(set) var queuedUplinks: [QueuedUplink] = []
|
||||
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
|
||||
private var downlinkSendTimes: [Date] = []
|
||||
private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = []
|
||||
/// True while a drain timer is armed, so a burst schedules at most one.
|
||||
private var downlinkDrainScheduled = false
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let now: () -> Date
|
||||
private static let enabledKey = "gateway.userEnabled"
|
||||
|
||||
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
|
||||
self.defaults = defaults
|
||||
self.now = now
|
||||
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
|
||||
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||
}
|
||||
|
||||
// MARK: - Toggle
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
guard enabled != isEnabled else { return }
|
||||
isEnabled = enabled
|
||||
defaults.set(enabled, forKey: Self.enabledKey)
|
||||
if !enabled {
|
||||
queuedUplinks.removeAll()
|
||||
pendingDownlinks.removeAll()
|
||||
uplinkDepositTimes.removeAll()
|
||||
}
|
||||
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
|
||||
onEnabledChanged?(enabled)
|
||||
}
|
||||
|
||||
// MARK: - Mesh carrier ingress (both roles)
|
||||
|
||||
/// Entry point for received `nostrCarrier` packets. `directedToUs` is
|
||||
/// true for packets addressed to this device (uplink deposits); false
|
||||
/// for broadcasts (downlink rebroadcasts from a gateway).
|
||||
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
|
||||
guard let carrier = NostrCarrierPacket.decode(payload) else {
|
||||
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
switch carrier.direction {
|
||||
case .toGateway:
|
||||
// Uplink deposits are directed; a broadcast toGateway is malformed.
|
||||
guard directedToUs else { return }
|
||||
handleUplinkDeposit(carrier, from: peerID)
|
||||
case .fromGateway:
|
||||
// Downlink rides broadcast only; a directed fromGateway is malformed.
|
||||
guard !directedToUs else { return }
|
||||
handleDownlinkBroadcast(carrier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Uplink (gateway role: mesh peer -> internet)
|
||||
|
||||
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
|
||||
guard isEnabled else { return }
|
||||
// Cheap structural checks first (parse, size, geohash, kind, #g tag,
|
||||
// age) — no crypto — so junk and stale replays are dropped before we
|
||||
// ever pay for a MainActor Schnorr verify.
|
||||
guard let event = structurallyValidEvent(from: carrier) else {
|
||||
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
||||
return
|
||||
}
|
||||
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
||||
// fromGateway-learned event is mesh-carried and must never be
|
||||
// re-published. Loop rule 2: repeat deposits of an already handled
|
||||
// event are absorbed. A replay of one valid deposit is short-circuited
|
||||
// here without a per-packet signature verify.
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!publishedEventIDs.contains(event.id),
|
||||
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||
return
|
||||
}
|
||||
// Consume the per-depositor rate token BEFORE the expensive verify so
|
||||
// a flood of distinct forged/junk deposits is bounded by cheap work,
|
||||
// not by main-actor Schnorr verifications.
|
||||
guard allowUplinkDeposit(from: depositor) else {
|
||||
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
// Only now pay for cryptographic verification; receivers verify again.
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
let accepted: Bool
|
||||
if relaysConnected?() ?? false {
|
||||
publish(event, geohash: carrier.geohash)
|
||||
accepted = true
|
||||
} else {
|
||||
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
|
||||
}
|
||||
|
||||
// Only render on our own timeline what we actually accepted for
|
||||
// publish or queue: a quota-dropped deposit is never published and,
|
||||
// being directed, no other peer will ever see it, so showing it would
|
||||
// diverge our timeline permanently from what reached the channel.
|
||||
if accepted, currentGeohash?() == carrier.geohash {
|
||||
injectInbound?(event)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish everything queued while relays were unreachable. Called when
|
||||
/// relay connectivity comes back.
|
||||
func flushQueuedUplinks() {
|
||||
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
|
||||
let queued = queuedUplinks
|
||||
queuedUplinks.removeAll()
|
||||
for item in queued where !publishedEventIDs.contains(item.event.id) {
|
||||
publish(item.event, geohash: item.geohash)
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ event: NostrEvent, geohash: String) {
|
||||
publishedEventIDs.insert(event.id)
|
||||
publishToRelays?(event, geohash)
|
||||
SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
|
||||
}
|
||||
|
||||
/// Returns true when the item was actually stored for later publish.
|
||||
@discardableResult
|
||||
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
||||
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
||||
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
|
||||
SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session)
|
||||
return false
|
||||
}
|
||||
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
||||
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
|
||||
}
|
||||
queuedUplinks.append(item)
|
||||
return true
|
||||
}
|
||||
|
||||
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
|
||||
let cutoff = now().addingTimeInterval(-60)
|
||||
var times = uplinkDepositTimes[depositor, default: []]
|
||||
times.removeAll { $0 < cutoff }
|
||||
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
|
||||
uplinkDepositTimes[depositor] = times
|
||||
return false
|
||||
}
|
||||
times.append(now())
|
||||
uplinkDepositTimes[depositor] = times
|
||||
// Bound the tracker itself against a churn of spoofed depositors.
|
||||
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
|
||||
uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Downlink (gateway role: internet -> mesh)
|
||||
|
||||
/// Called for every event the gateway's own geohash-channel subscription
|
||||
/// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on
|
||||
/// the mesh, within the airtime budget.
|
||||
func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) {
|
||||
guard isEnabled, broadcastToMesh != nil else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
// Freshness + geohash gate BEFORE spending any budget. A channel
|
||||
// (re)subscribe backfills up to an hour of history (limit 200), but
|
||||
// every receiver's `validatedEvent` drops anything older than the
|
||||
// same window — so rebroadcasting backfill would burn the whole
|
||||
// per-minute budget on events no mesh peer accepts. Also require the
|
||||
// event's own `#g` tag to match the carrier geohash.
|
||||
guard isFresh(event),
|
||||
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
|
||||
return
|
||||
}
|
||||
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
||||
// mesh. Loop rule 2 (self-echo): never rebroadcast an event this
|
||||
// gateway itself uplinked (`publishedEventIDs`) — it originated on this
|
||||
// very mesh, so our own relay subscription echoing it back must not
|
||||
// double the BLE airtime by pushing it out again. Loop rule 2
|
||||
// (downlink): rebroadcast each genuine inbound relay event at most once
|
||||
// — but mark only AFTER it is actually sent (in `drainPendingDownlinks`),
|
||||
// so an event dropped by the queue overflow stays retryable on relay
|
||||
// redelivery. Guard against a redelivery re-queueing an event that is
|
||||
// still waiting to be sent.
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!publishedEventIDs.contains(event.id),
|
||||
!rebroadcastEventIDs.contains(event.id),
|
||||
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
|
||||
return
|
||||
}
|
||||
// Verify before spending BLE airtime; receivers verify again.
|
||||
guard event.isValidSignature() else { return }
|
||||
|
||||
pendingDownlinks.append((event, geohash))
|
||||
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||
// Bandwidth guard: drop-oldest — fresher chat is worth more. The
|
||||
// dropped event is not yet in `rebroadcastEventIDs`, so a later
|
||||
// relay redelivery can still carry it.
|
||||
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
||||
}
|
||||
drainPendingDownlinks()
|
||||
}
|
||||
|
||||
private func drainPendingDownlinks() {
|
||||
let cutoff = now().addingTimeInterval(-60)
|
||||
downlinkSendTimes.removeAll { $0 < cutoff }
|
||||
while !pendingDownlinks.isEmpty,
|
||||
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
|
||||
let (event, geohash) = pendingDownlinks.removeFirst()
|
||||
// A queued event may have aged past the window while it waited;
|
||||
// don't burn airtime on what receivers would now drop.
|
||||
guard isFresh(event) else { continue }
|
||||
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
||||
let payload = carrier.encode() else { continue }
|
||||
broadcastToMesh?(payload)
|
||||
// Mark-after-send: only now is the relay event definitively
|
||||
// rebroadcast (loop rule 2).
|
||||
rebroadcastEventIDs.insert(event.id)
|
||||
downlinkSendTimes.append(now())
|
||||
}
|
||||
// Budget exhausted with events still queued: arm a timer to drain when
|
||||
// the window frees, instead of stranding them until the next inbound
|
||||
// relay event (which may never come on a channel that went quiet).
|
||||
scheduleDownlinkDrainIfNeeded()
|
||||
}
|
||||
|
||||
/// Arms a single timer to drain the backlog once the per-minute window
|
||||
/// frees. No-op when nothing is pending or a drain is already scheduled.
|
||||
private func scheduleDownlinkDrainIfNeeded() {
|
||||
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
|
||||
// The window frees when the oldest recorded send ages out of 60s.
|
||||
let oldest = downlinkSendTimes.min() ?? now()
|
||||
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
||||
downlinkDrainScheduled = true
|
||||
let fire: @MainActor () -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.downlinkDrainScheduled = false
|
||||
self.drainPendingDownlinks()
|
||||
}
|
||||
if let scheduleDrainTimer {
|
||||
scheduleDrainTimer(delay, fire)
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
fire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Downlink (receiver role: carried event arrives over mesh)
|
||||
|
||||
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
|
||||
guard let event = validatedEvent(from: carrier) else { return }
|
||||
// Mark only AFTER signature verification, so a forged copy carrying a
|
||||
// real event's ID cannot poison the never-republish set, and use the
|
||||
// marking as dedup: the same broadcast relayed along several mesh
|
||||
// paths injects once (the pipeline's Nostr event-ID cache additionally
|
||||
// dedups against our own relay subscription).
|
||||
guard meshBroadcastEventIDs.insert(event.id) else { return }
|
||||
// Only inject events for the channel we're viewing; the inbound
|
||||
// pipeline files public messages under the current geohash.
|
||||
guard currentGeohash?() == carrier.geohash else { return }
|
||||
injectInbound?(event)
|
||||
}
|
||||
|
||||
// MARK: - Uplink (sender role: mesh-only peer with no relays)
|
||||
|
||||
/// Hands a locally signed event to a mesh gateway peer when we have no
|
||||
/// working relay connection. Returns true when the event was sent.
|
||||
///
|
||||
/// v1 is deliberately fire-and-forget: no gateway ack. The event also
|
||||
/// stays in `NostrRelayManager`'s own pending queue, so if our internet
|
||||
/// comes back the relays dedup the duplicate publish by event ID.
|
||||
///
|
||||
/// Loop rule 3: call sites only pass freshly composed events (see
|
||||
/// `GeohashSubscriptionManager.sendGeohash`); received carrier events
|
||||
/// never reach this path, and the mesh-carried guard below backstops it.
|
||||
func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool {
|
||||
if relaysConnected?() ?? true { return false }
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!publishedEventIDs.contains(event.id) else {
|
||||
return false
|
||||
}
|
||||
// A single gateway is enough — relays fan out from there, and BLE
|
||||
// airtime is precious.
|
||||
guard let gateway = availableGatewayPeers?().first else { return false }
|
||||
guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event),
|
||||
let payload = carrier.encode() else {
|
||||
return false
|
||||
}
|
||||
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
|
||||
SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Structural and cryptographic checks every carried event must pass
|
||||
/// before a gateway publishes it or a receiver displays it. Ordered
|
||||
/// cheap-first; Schnorr verification runs last.
|
||||
private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
|
||||
guard let event = structurallyValidEvent(from: carrier),
|
||||
event.isValidSignature() else {
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/// The cheap half of `validatedEvent`: parse + size + geohash + kind +
|
||||
/// `#g` tag + freshness, with NO signature verification. Callers that can
|
||||
/// dedup or rate-limit on the carried ID run this first so the expensive
|
||||
/// Schnorr verify is reached only for events that survive the cheap gates.
|
||||
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
|
||||
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
|
||||
Self.isValidGeohash(carrier.geohash),
|
||||
let event = carrier.event(),
|
||||
event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }),
|
||||
isFresh(event) else {
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/// True when `event.created_at` is within the accepted clock skew — the
|
||||
/// SAME freshness window receivers enforce, so a gateway never spends
|
||||
/// airtime on events every receiver would drop as stale.
|
||||
private func isFresh(_ event: NostrEvent) -> Bool {
|
||||
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
|
||||
}
|
||||
|
||||
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count)
|
||||
&& geohash.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
|
||||
/// evicted when full.
|
||||
private struct BoundedIDSet {
|
||||
private var members: Set<String> = []
|
||||
private var order: [String] = []
|
||||
let capacity: Int
|
||||
|
||||
init(capacity: Int) {
|
||||
self.capacity = capacity
|
||||
}
|
||||
|
||||
func contains(_ id: String) -> Bool {
|
||||
members.contains(id)
|
||||
}
|
||||
|
||||
/// Returns false when the ID was already present.
|
||||
@discardableResult
|
||||
mutating func insert(_ id: String) -> Bool {
|
||||
guard members.insert(id).inserted else { return false }
|
||||
order.append(id)
|
||||
if order.count > capacity {
|
||||
members.remove(order.removeFirst())
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ struct SyncTypeFlags: OptionSet {
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
||||
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||
// would waste airtime and extend their lifetime.
|
||||
case .nostrCarrier: return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ final class ChatViewModelBootstrapper {
|
||||
configureNoiseCallbacks()
|
||||
bindTransferProgress()
|
||||
configureGeoChannels()
|
||||
configureGateway()
|
||||
bindTeleportState()
|
||||
requestNotifications()
|
||||
registerObservers()
|
||||
@@ -244,6 +245,72 @@ private extension ChatViewModelBootstrapper {
|
||||
)
|
||||
}
|
||||
|
||||
/// Wires the gateway-mode policy layer (`GatewayService`) to the mesh
|
||||
/// transport, the relay manager, and the inbound Nostr pipeline. All
|
||||
/// dependencies are closures so the service stays unit-testable with
|
||||
/// fakes.
|
||||
func configureGateway() {
|
||||
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
|
||||
// has no carrier packets to bridge.
|
||||
guard let bleService = viewModel.meshService as? BLEService else { return }
|
||||
let gateway = GatewayService.shared
|
||||
|
||||
gateway.publishToRelays = { event, geohash in
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
// Symmetric with the local send path (GeohashSubscriptionManager
|
||||
// .sendGeohash): with no known geo relay, refuse rather than
|
||||
// publish to default relays no geo subscriber reads — that would
|
||||
// be silent dead traffic, not delivery.
|
||||
guard !relays.isEmpty else {
|
||||
SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session)
|
||||
return
|
||||
}
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
}
|
||||
gateway.broadcastToMesh = { [weak bleService] payload in
|
||||
bleService?.broadcastNostrCarrier(payload)
|
||||
}
|
||||
gateway.sendToGatewayPeer = { [weak bleService] payload, peer in
|
||||
bleService?.sendNostrCarrier(payload, to: peer) ?? false
|
||||
}
|
||||
gateway.availableGatewayPeers = { [weak bleService] in
|
||||
bleService?.reachableGatewayPeers() ?? []
|
||||
}
|
||||
gateway.relaysConnected = { NostrRelayManager.shared.isConnected }
|
||||
gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash }
|
||||
// Carried events enter the same pipeline as relay-received events so
|
||||
// blocking, rate limits, dedup, and rendering behave identically.
|
||||
gateway.injectInbound = { [weak viewModel] event in
|
||||
viewModel?.handleNostrEvent(event)
|
||||
}
|
||||
// The capability bit is advertised ONLY while the toggle is on; a
|
||||
// change forces a re-announce so peers learn promptly.
|
||||
gateway.onEnabledChanged = { [weak bleService] enabled in
|
||||
bleService?.setLocalCapability(.gateway, enabled: enabled)
|
||||
}
|
||||
bleService.onNostrCarrierPacket = { payload, from, directedToUs in
|
||||
GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs)
|
||||
}
|
||||
|
||||
// Uplinks deposited while relays were unreachable flush on reconnect.
|
||||
NostrRelayManager.shared.$isConnected
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { connected in
|
||||
if connected {
|
||||
GatewayService.shared.flushQueuedUplinks()
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
// Apply the persisted toggle at launch.
|
||||
if gateway.isEnabled {
|
||||
bleService.setLocalCapability(.gateway, enabled: true)
|
||||
}
|
||||
}
|
||||
|
||||
func bindTeleportState() {
|
||||
viewModel.locationManager.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
@@ -115,6 +115,9 @@ final class GeohashSubscriptionManager {
|
||||
private weak var context: (any GeohashSubscriptionContext)?
|
||||
private let inbound: NostrInboundPipeline
|
||||
private let presence: GeoPresenceTracker
|
||||
/// Geohashes already told "sent via mesh gateway" this session, so the
|
||||
/// notice appears once per channel instead of once per message.
|
||||
private var gatewayNoticeGeohashes = Set<String>()
|
||||
|
||||
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
|
||||
self.context = context
|
||||
@@ -145,6 +148,9 @@ final class GeohashSubscriptionManager {
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.inbound.subscribeNostrEvent(event)
|
||||
// Gateway downlink: rebroadcast relay events for the viewed
|
||||
// channel onto the mesh (no-op unless gateway mode is on).
|
||||
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +241,9 @@ final class GeohashSubscriptionManager {
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.inbound.handleNostrEvent(event)
|
||||
// Gateway downlink: rebroadcast relay events for the viewed
|
||||
// channel onto the mesh (no-op unless gateway mode is on).
|
||||
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +289,23 @@ final class GeohashSubscriptionManager {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
// Mesh gateway uplink: with no working relay connection, hand the
|
||||
// locally signed event to a mesh peer advertising the gateway
|
||||
// capability (keys never leave this device — only the finished,
|
||||
// signed event travels). Uplink is only ever attempted here, for a
|
||||
// freshly composed event, never for received carrier events (loop
|
||||
// rule 3 in GatewayService).
|
||||
if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash),
|
||||
gatewayNoticeGeohashes.insert(channel.geohash).inserted {
|
||||
context.addPublicSystemMessage(
|
||||
String(
|
||||
localized: "system.gateway.sent_via_mesh",
|
||||
defaultValue: "sent via mesh gateway",
|
||||
comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
|
||||
SecureLogger.debug(
|
||||
|
||||
@@ -94,6 +94,19 @@ struct ContentHeaderView: View {
|
||||
}()
|
||||
|
||||
HStack(spacing: 2) {
|
||||
if locationChannelsModel.gatewayEnabled {
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary.opacity(0.8))
|
||||
.headerTapTarget()
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator")
|
||||
)
|
||||
}
|
||||
|
||||
if carriedMailCount > 0 {
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
|
||||
@@ -27,6 +27,8 @@ struct LocationChannelsSheet: View {
|
||||
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||||
static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title"
|
||||
static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle"
|
||||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
@@ -244,6 +246,8 @@ struct LocationChannelsSheet: View {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
gatewayToggleSection
|
||||
.padding(.top, 8)
|
||||
Button(action: SystemSettings.location.open) {
|
||||
Text(Strings.removeAccess)
|
||||
.bitchatFont(size: 12)
|
||||
@@ -508,6 +512,32 @@ extension LocationChannelsSheet {
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private var gatewayToggleBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { locationChannelsModel.gatewayEnabled },
|
||||
set: { locationChannelsModel.setGatewayEnabled($0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var gatewayToggleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: gatewayToggleBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Strings.gatewayTitle)
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.foregroundColor(palette.primary)
|
||||
Text(Strings.gatewaySubtitle)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||
}
|
||||
.padding(12)
|
||||
.background(palette.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private var standardGreen: Color { palette.primary }
|
||||
private var standardBlue: Color { palette.accentBlue }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// NostrCarrierPacketTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Nostr carrier packet TLV")
|
||||
struct NostrCarrierPacketTests {
|
||||
private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent {
|
||||
let identity = try NostrIdentity.generate()
|
||||
return try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: "tester"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("round-trips both directions with the signed event intact")
|
||||
func roundTrip() throws {
|
||||
let event = try makeEvent()
|
||||
for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] {
|
||||
let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event))
|
||||
let encoded = try #require(packet.encode())
|
||||
let decoded = try #require(NostrCarrierPacket.decode(encoded))
|
||||
|
||||
#expect(decoded == packet)
|
||||
#expect(decoded.direction == direction)
|
||||
#expect(decoded.geohash == "u4pruy")
|
||||
|
||||
// The carried event survives byte-exact: same ID, and the
|
||||
// signature still verifies after the mesh hop.
|
||||
let carried = try #require(decoded.event())
|
||||
#expect(carried.id == event.id)
|
||||
#expect(carried.sig == event.sig)
|
||||
#expect(carried.isValidSignature())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("rejects an oversized event at construction and at decode")
|
||||
func oversizedRejected() throws {
|
||||
let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1)
|
||||
#expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil)
|
||||
|
||||
// Hand-build the TLV bytes to bypass the initializer's cap.
|
||||
var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue])
|
||||
let geohash = Data("u4pruy".utf8)
|
||||
data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)])
|
||||
data.append(geohash)
|
||||
data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)])
|
||||
data.append(oversized)
|
||||
#expect(NostrCarrierPacket.decode(data) == nil)
|
||||
}
|
||||
|
||||
@Test("rejects an over-length or empty geohash")
|
||||
func geohashBoundsEnforced() throws {
|
||||
let event = try makeEvent()
|
||||
#expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil)
|
||||
#expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil)
|
||||
#expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil)
|
||||
}
|
||||
|
||||
@Test("skips unknown TLVs for forward compatibility")
|
||||
func unknownTLVSkipped() throws {
|
||||
let event = try makeEvent()
|
||||
let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event))
|
||||
var encoded = try #require(packet.encode())
|
||||
// Append an unknown TLV (type 0x7F, 2-byte value).
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||
let decoded = try #require(NostrCarrierPacket.decode(encoded))
|
||||
#expect(decoded == packet)
|
||||
}
|
||||
|
||||
@Test("rejects truncated and missing-field payloads")
|
||||
func malformedRejected() throws {
|
||||
let event = try makeEvent()
|
||||
let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event))
|
||||
let encoded = try #require(packet.encode())
|
||||
|
||||
// Truncation anywhere inside the last TLV fails cleanly.
|
||||
#expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil)
|
||||
#expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil)
|
||||
#expect(NostrCarrierPacket.decode(Data()) == nil)
|
||||
|
||||
// Direction TLV alone (missing geohash and event) fails.
|
||||
#expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil)
|
||||
// Unknown direction value fails.
|
||||
#expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
//
|
||||
// GatewayServiceTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Gateway mode policy")
|
||||
@MainActor
|
||||
struct GatewayServiceTests {
|
||||
private static let geohash = "u4pruy"
|
||||
|
||||
/// Closure-injected harness around `GatewayService` recording every
|
||||
/// side effect, with a controllable clock and relay connectivity.
|
||||
@MainActor
|
||||
private final class Fixture {
|
||||
private final class ClockBox {
|
||||
var now = Date()
|
||||
}
|
||||
|
||||
var relaysConnected = true
|
||||
var currentGeohash: String? = GatewayServiceTests.geohash
|
||||
var gatewayPeers: [PeerID] = []
|
||||
var sendToGatewaySucceeds = true
|
||||
|
||||
private(set) var published: [(event: NostrEvent, geohash: String)] = []
|
||||
private(set) var broadcasts: [Data] = []
|
||||
private(set) var injected: [NostrEvent] = []
|
||||
private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
|
||||
private(set) var enabledChanges: [Bool] = []
|
||||
private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = []
|
||||
|
||||
private let clock = ClockBox()
|
||||
let defaults: UserDefaults
|
||||
let service: GatewayService
|
||||
|
||||
init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") {
|
||||
defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let clock = clock
|
||||
service = GatewayService(defaults: defaults) { clock.now }
|
||||
service.publishToRelays = { [weak self] event, geohash in
|
||||
self?.published.append((event, geohash))
|
||||
}
|
||||
service.broadcastToMesh = { [weak self] payload in
|
||||
self?.broadcasts.append(payload)
|
||||
}
|
||||
service.sendToGatewayPeer = { [weak self] payload, peer in
|
||||
guard let self, self.sendToGatewaySucceeds else { return false }
|
||||
self.uplinkSends.append((payload, peer))
|
||||
return true
|
||||
}
|
||||
service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] }
|
||||
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
|
||||
service.currentGeohash = { [weak self] in self?.currentGeohash }
|
||||
service.injectInbound = { [weak self] event in self?.injected.append(event) }
|
||||
service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) }
|
||||
// Capture drain timers instead of arming a real Task so the drain
|
||||
// is deterministic under the fake clock.
|
||||
service.scheduleDrainTimer = { [weak self] delay, work in
|
||||
self?.scheduledDrains.append((delay, work))
|
||||
}
|
||||
if enabled {
|
||||
service.setEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
func advance(_ seconds: TimeInterval) {
|
||||
clock.now = clock.now.addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
/// Fires every currently-scheduled drain timer (simulating the window
|
||||
/// freeing), as the real Task would after its delay.
|
||||
func fireScheduledDrains() {
|
||||
let due = scheduledDrains
|
||||
scheduledDrains.removeAll()
|
||||
for item in due { item.work() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Event helpers
|
||||
|
||||
private func makeEvent(
|
||||
geohash: String = GatewayServiceTests.geohash,
|
||||
content: String = "hello \(UUID().uuidString.prefix(8))"
|
||||
) throws -> NostrEvent {
|
||||
let identity = try NostrIdentity.generate()
|
||||
return try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: "tester"
|
||||
)
|
||||
}
|
||||
|
||||
/// A copy of `event` with tampered content but the original ID and
|
||||
/// signature — what a forging gateway or mesh peer would produce.
|
||||
private func forge(_ event: NostrEvent) throws -> NostrEvent {
|
||||
let dict: [String: Any] = [
|
||||
"id": event.id,
|
||||
"pubkey": event.pubkey,
|
||||
"created_at": event.created_at,
|
||||
"kind": event.kind,
|
||||
"tags": event.tags,
|
||||
"content": event.content + " (tampered)",
|
||||
"sig": event.sig ?? ""
|
||||
]
|
||||
return try NostrEvent(from: dict)
|
||||
}
|
||||
|
||||
private func carrierPayload(
|
||||
_ event: NostrEvent,
|
||||
direction: NostrCarrierPacket.Direction = .toGateway,
|
||||
geohash: String = GatewayServiceTests.geohash
|
||||
) throws -> Data {
|
||||
let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event))
|
||||
return try #require(packet.encode())
|
||||
}
|
||||
|
||||
private func deposit(
|
||||
_ event: NostrEvent,
|
||||
into fixture: Fixture,
|
||||
from depositor: PeerID = PeerID(str: "1122334455667788"),
|
||||
geohash: String = GatewayServiceTests.geohash
|
||||
) throws {
|
||||
let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash)
|
||||
fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true)
|
||||
}
|
||||
|
||||
// MARK: - Uplink verification gates
|
||||
|
||||
@Test("publishes a verified deposit to the geo relays")
|
||||
func verifiedDepositPublished() throws {
|
||||
let fixture = Fixture()
|
||||
let event = try makeEvent()
|
||||
try deposit(event, into: fixture)
|
||||
|
||||
#expect(fixture.published.count == 1)
|
||||
#expect(fixture.published.first?.event.id == event.id)
|
||||
#expect(fixture.published.first?.geohash == Self.geohash)
|
||||
// Viewing the same geohash: the carried message shows on our own timeline.
|
||||
#expect(fixture.injected.map(\.id) == [event.id])
|
||||
}
|
||||
|
||||
@Test("rejects a forged signature")
|
||||
func forgedSignatureRejected() throws {
|
||||
let fixture = Fixture()
|
||||
let forged = try forge(try makeEvent())
|
||||
try deposit(forged, into: fixture)
|
||||
|
||||
#expect(fixture.published.isEmpty)
|
||||
#expect(fixture.injected.isEmpty)
|
||||
}
|
||||
|
||||
@Test("rejects wrong kind, geohash mismatch, and stale events")
|
||||
func structuralGates() throws {
|
||||
let fixture = Fixture()
|
||||
|
||||
// Wrong kind (kind-1 text note instead of kind-20000 ephemeral).
|
||||
let identity = try NostrIdentity.generate()
|
||||
let note = try NostrProtocol.createGeohashTextNote(
|
||||
content: "note",
|
||||
geohash: Self.geohash,
|
||||
senderIdentity: identity
|
||||
)
|
||||
try deposit(note, into: fixture)
|
||||
#expect(fixture.published.isEmpty)
|
||||
|
||||
// Carrier geohash disagreeing with the event's #g tag.
|
||||
let mismatched = try makeEvent(geohash: "9q8yyk")
|
||||
try deposit(mismatched, into: fixture, geohash: Self.geohash)
|
||||
#expect(fixture.published.isEmpty)
|
||||
|
||||
// Stale event (beyond accepted clock skew).
|
||||
let stale = try makeEvent()
|
||||
fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60)
|
||||
try deposit(stale, into: fixture)
|
||||
#expect(fixture.published.isEmpty)
|
||||
}
|
||||
|
||||
@Test("does nothing while the toggle is off")
|
||||
func disabledGatewayIgnoresDeposits() throws {
|
||||
let fixture = Fixture(enabled: false)
|
||||
try deposit(try makeEvent(), into: fixture)
|
||||
#expect(fixture.published.isEmpty)
|
||||
#expect(fixture.service.queuedUplinks.isEmpty)
|
||||
|
||||
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Uplink quotas and rate limit
|
||||
|
||||
@Test("rate-limits deposits per depositor per minute")
|
||||
func uplinkRateLimit() throws {
|
||||
let fixture = Fixture()
|
||||
let depositor = PeerID(str: "aabbccddeeff0011")
|
||||
|
||||
for _ in 0..<GatewayService.Limits.uplinkEventsPerMinutePerDepositor {
|
||||
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||
}
|
||||
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor)
|
||||
|
||||
// One over budget inside the window: dropped.
|
||||
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor)
|
||||
|
||||
// Another depositor is unaffected.
|
||||
try deposit(try makeEvent(), into: fixture, from: PeerID(str: "0011223344556677"))
|
||||
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor + 1)
|
||||
|
||||
// The window slides.
|
||||
fixture.advance(61)
|
||||
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor + 2)
|
||||
}
|
||||
|
||||
@Test("bounds the offline uplink queue per depositor and in total, evicting oldest")
|
||||
func uplinkQueueQuotas() throws {
|
||||
let fixture = Fixture()
|
||||
fixture.relaysConnected = false
|
||||
|
||||
let depositorA = PeerID(str: "1111111111111111")
|
||||
var firstFromA: NostrEvent?
|
||||
for i in 0..<(GatewayService.Limits.maxQueuedUplinksPerDepositor + 1) {
|
||||
let event = try makeEvent()
|
||||
if i == 0 { firstFromA = event }
|
||||
try deposit(event, into: fixture, from: depositorA)
|
||||
}
|
||||
// The sixth deposit from the same depositor is rejected.
|
||||
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||
|
||||
// Fill the global queue with other depositors.
|
||||
var filler = GatewayService.Limits.maxQueuedUplinksPerDepositor
|
||||
var suffix = 2
|
||||
while filler < GatewayService.Limits.maxQueuedUplinks {
|
||||
let depositor = PeerID(str: String(repeating: "\(suffix)", count: 16))
|
||||
for _ in 0..<GatewayService.Limits.maxQueuedUplinksPerDepositor {
|
||||
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||
}
|
||||
filler += GatewayService.Limits.maxQueuedUplinksPerDepositor
|
||||
suffix += 1
|
||||
}
|
||||
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinks)
|
||||
|
||||
// One more from a fresh depositor evicts the oldest queued deposit.
|
||||
let newest = try makeEvent()
|
||||
try deposit(newest, into: fixture, from: PeerID(str: "9999999999999999"))
|
||||
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinks)
|
||||
#expect(fixture.service.queuedUplinks.contains { $0.event.id == newest.id })
|
||||
#expect(!fixture.service.queuedUplinks.contains { $0.event.id == firstFromA?.id })
|
||||
|
||||
// Relay connectivity returning flushes the queue.
|
||||
fixture.relaysConnected = true
|
||||
fixture.service.flushQueuedUplinks()
|
||||
#expect(fixture.published.count == GatewayService.Limits.maxQueuedUplinks)
|
||||
#expect(fixture.service.queuedUplinks.isEmpty)
|
||||
}
|
||||
|
||||
@Test("absorbs repeat deposits of the same event")
|
||||
func repeatDepositAbsorbed() throws {
|
||||
let fixture = Fixture()
|
||||
let event = try makeEvent()
|
||||
try deposit(event, into: fixture)
|
||||
try deposit(event, into: fixture, from: PeerID(str: "8877665544332211"))
|
||||
#expect(fixture.published.count == 1)
|
||||
}
|
||||
|
||||
@Test("a quota-dropped deposit is not rendered on the local timeline")
|
||||
func quotaDroppedDepositNotInjected() throws {
|
||||
let fixture = Fixture()
|
||||
fixture.relaysConnected = false
|
||||
let depositor = PeerID(str: "1111111111111111")
|
||||
|
||||
// Fill this depositor's offline queue to its per-depositor cap; each
|
||||
// accepted deposit is rendered locally (we view that geohash).
|
||||
for _ in 0..<GatewayService.Limits.maxQueuedUplinksPerDepositor {
|
||||
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||
}
|
||||
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||
#expect(fixture.injected.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||
|
||||
// One past the cap: dropped by the queue quota, so it is neither
|
||||
// queued nor shown — no phantom local message.
|
||||
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||
#expect(fixture.injected.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||
}
|
||||
|
||||
// MARK: - Loop prevention
|
||||
|
||||
@Test("never re-publishes or re-carries an event learned from a fromGateway broadcast")
|
||||
func meshCarriedNeverRepublished() throws {
|
||||
let fixture = Fixture()
|
||||
let event = try makeEvent()
|
||||
let broadcast = try carrierPayload(event, direction: .fromGateway)
|
||||
|
||||
// Another gateway rebroadcast this event onto the mesh; we saw it.
|
||||
fixture.service.handleMeshCarrier(broadcast, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||
#expect(fixture.injected.map(\.id) == [event.id])
|
||||
|
||||
// Rule: a deposit of the same event must never be published…
|
||||
try deposit(event, into: fixture)
|
||||
#expect(fixture.published.isEmpty)
|
||||
|
||||
// …a relay echo of it must never be rebroadcast (we'd loop it back)…
|
||||
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.isEmpty)
|
||||
|
||||
// …and it must never be re-uplinked from this device.
|
||||
fixture.relaysConnected = false
|
||||
fixture.gatewayPeers = [PeerID(str: "8877665544332211")]
|
||||
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||
#expect(fixture.uplinkSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test("rebroadcasts a relay event at most once, absorbing echoes")
|
||||
func relayEventRebroadcastOnce() throws {
|
||||
let fixture = Fixture()
|
||||
let event = try makeEvent()
|
||||
|
||||
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.count == 1)
|
||||
|
||||
// The rebroadcast payload decodes as a fromGateway carrier for the
|
||||
// same signed event.
|
||||
let payload = try #require(fixture.broadcasts.first)
|
||||
let decoded = try #require(NostrCarrierPacket.decode(payload))
|
||||
#expect(decoded.direction == .fromGateway)
|
||||
#expect(decoded.event()?.id == event.id)
|
||||
}
|
||||
|
||||
@Test("never downlink-rebroadcasts an event it uplinked and saw echo back")
|
||||
func uplinkedEventNotSelfEchoed() throws {
|
||||
let fixture = Fixture()
|
||||
let event = try makeEvent()
|
||||
|
||||
// A mesh-only peer deposits an event; the gateway uplinks (publishes)
|
||||
// it to the relays (loop rule 2 records it in publishedEventIDs).
|
||||
try deposit(event, into: fixture)
|
||||
#expect(fixture.published.map(\.event.id) == [event.id])
|
||||
|
||||
// The gateway's own geohash subscription now delivers that same event
|
||||
// right back (~0.15s later on device). It originated on this mesh, so
|
||||
// rebroadcasting it would double BLE airtime — the device-confirmed
|
||||
// self-echo. It must be suppressed.
|
||||
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.isEmpty)
|
||||
|
||||
// Sanity: a genuine inbound-from-internet event (never uplinked here)
|
||||
// still downlink-rebroadcasts normally.
|
||||
let inbound = try makeEvent()
|
||||
fixture.service.rebroadcastRelayEvent(inbound, geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.count == 1)
|
||||
let payload = try #require(fixture.broadcasts.first)
|
||||
let decoded = try #require(NostrCarrierPacket.decode(payload))
|
||||
#expect(decoded.event()?.id == inbound.id)
|
||||
}
|
||||
|
||||
@Test("a forged broadcast cannot poison the loop-prevention set")
|
||||
func forgedBroadcastDoesNotPoison() throws {
|
||||
let fixture = Fixture()
|
||||
let event = try makeEvent()
|
||||
let forgedPayload = try carrierPayload(try forge(event), direction: .fromGateway)
|
||||
|
||||
fixture.service.handleMeshCarrier(forgedPayload, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||
#expect(fixture.injected.isEmpty)
|
||||
|
||||
// The genuine event still uplinks: the forged copy (sharing its ID)
|
||||
// was rejected before the mesh-carried marking.
|
||||
try deposit(event, into: fixture)
|
||||
#expect(fixture.published.map(\.event.id) == [event.id])
|
||||
}
|
||||
|
||||
// MARK: - Downlink bandwidth guard
|
||||
|
||||
@Test("caps mesh rebroadcasts per minute, queueing with drop-oldest")
|
||||
func downlinkBudget() throws {
|
||||
let fixture = Fixture()
|
||||
let overBudget = GatewayService.Limits.downlinkEventsPerMinute + 5
|
||||
|
||||
var events: [NostrEvent] = []
|
||||
for _ in 0..<overBudget {
|
||||
let event = try makeEvent()
|
||||
events.append(event)
|
||||
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||
}
|
||||
#expect(fixture.broadcasts.count == GatewayService.Limits.downlinkEventsPerMinute)
|
||||
|
||||
// Once the window slides, the next relay event drains the backlog too.
|
||||
fixture.advance(61)
|
||||
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.count == overBudget + 1)
|
||||
}
|
||||
|
||||
@Test("does not spend downlink budget on stale backfill or geohash mismatch")
|
||||
func downlinkDropsStaleAndMismatched() throws {
|
||||
let fixture = Fixture()
|
||||
|
||||
// A fresh, matching event rebroadcasts.
|
||||
let fresh = try makeEvent()
|
||||
// An event whose #g tag disagrees with the carrier geohash.
|
||||
let mismatched = try makeEvent(geohash: "9q8yyk")
|
||||
// An event that will age past the receiver window before we offer it.
|
||||
let willBeStale = try makeEvent()
|
||||
|
||||
fixture.service.rebroadcastRelayEvent(fresh, geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.count == 1)
|
||||
|
||||
fixture.service.rebroadcastRelayEvent(mismatched, geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.count == 1) // geohash mismatch: dropped pre-budget
|
||||
|
||||
fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60)
|
||||
fixture.service.rebroadcastRelayEvent(willBeStale, geohash: Self.geohash)
|
||||
#expect(fixture.broadcasts.count == 1) // stale backfill: dropped pre-budget
|
||||
}
|
||||
|
||||
@Test("drains a downlink backlog on the scheduled timer when the channel goes quiet")
|
||||
func downlinkDrainsOnTimer() throws {
|
||||
let fixture = Fixture()
|
||||
let overBudget = GatewayService.Limits.downlinkEventsPerMinute + 5
|
||||
|
||||
for _ in 0..<overBudget {
|
||||
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
|
||||
}
|
||||
// Budget spent; the tail is queued and a drain timer was armed.
|
||||
#expect(fixture.broadcasts.count == GatewayService.Limits.downlinkEventsPerMinute)
|
||||
#expect(!fixture.scheduledDrains.isEmpty)
|
||||
|
||||
// The channel goes quiet — no new inbound event. The window frees and
|
||||
// the timer fires on its own, draining the remaining backlog.
|
||||
fixture.advance(61)
|
||||
fixture.fireScheduledDrains()
|
||||
#expect(fixture.broadcasts.count == overBudget)
|
||||
}
|
||||
|
||||
// MARK: - Receiver downlink handling
|
||||
|
||||
@Test("injects a carried event once, even when broadcast twice")
|
||||
func downlinkInjectedOnce() throws {
|
||||
let fixture = Fixture(enabled: false) // receivers need no toggle
|
||||
let event = try makeEvent()
|
||||
let payload = try carrierPayload(event, direction: .fromGateway)
|
||||
|
||||
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "7766554433221100"), directedToUs: false)
|
||||
#expect(fixture.injected.map(\.id) == [event.id])
|
||||
}
|
||||
|
||||
@Test("only injects events for the geohash channel being viewed")
|
||||
func downlinkChannelScoped() throws {
|
||||
let fixture = Fixture(enabled: false)
|
||||
fixture.currentGeohash = "9q8yyk"
|
||||
let event = try makeEvent()
|
||||
let payload = try carrierPayload(event, direction: .fromGateway)
|
||||
|
||||
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||
#expect(fixture.injected.isEmpty)
|
||||
}
|
||||
|
||||
@Test("a directed fromGateway or broadcast toGateway carrier is malformed and dropped")
|
||||
func directionMisuseDropped() throws {
|
||||
let fixture = Fixture()
|
||||
let event = try makeEvent()
|
||||
|
||||
let downlink = try carrierPayload(event, direction: .fromGateway)
|
||||
fixture.service.handleMeshCarrier(downlink, from: PeerID(str: "8877665544332211"), directedToUs: true)
|
||||
#expect(fixture.injected.isEmpty)
|
||||
|
||||
let uplink = try carrierPayload(event, direction: .toGateway)
|
||||
fixture.service.handleMeshCarrier(uplink, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||
#expect(fixture.published.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Mesh-only sender uplink
|
||||
|
||||
@Test("uplinks via a mesh gateway only when relays are down and a gateway exists")
|
||||
func uplinkViaMeshConditions() throws {
|
||||
let fixture = Fixture(enabled: false)
|
||||
let gatewayPeer = PeerID(str: "8877665544332211")
|
||||
let event = try makeEvent()
|
||||
|
||||
// Relays working: no mesh uplink.
|
||||
fixture.relaysConnected = true
|
||||
fixture.gatewayPeers = [gatewayPeer]
|
||||
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||
|
||||
// Relays down, no gateway around: no mesh uplink.
|
||||
fixture.relaysConnected = false
|
||||
fixture.gatewayPeers = []
|
||||
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||
|
||||
// Relays down and a gateway peer advertised: uplink goes out.
|
||||
fixture.gatewayPeers = [gatewayPeer]
|
||||
#expect(fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||
#expect(fixture.uplinkSends.count == 1)
|
||||
#expect(fixture.uplinkSends.first?.peer == gatewayPeer)
|
||||
|
||||
let sentPayload = try #require(fixture.uplinkSends.first?.payload)
|
||||
let sent = try #require(NostrCarrierPacket.decode(sentPayload))
|
||||
#expect(sent.direction == .toGateway)
|
||||
#expect(sent.geohash == Self.geohash)
|
||||
#expect(sent.event()?.id == event.id)
|
||||
|
||||
// A failed transport hand-off reports false.
|
||||
fixture.sendToGatewaySucceeds = false
|
||||
#expect(!fixture.service.uplinkViaMesh(event: try makeEvent(), geohash: Self.geohash))
|
||||
}
|
||||
|
||||
@Test("uplinkViaMesh backstop refuses an event this gateway already published")
|
||||
func uplinkViaMeshBackstopsPublished() throws {
|
||||
let fixture = Fixture()
|
||||
let event = try makeEvent()
|
||||
|
||||
// This gateway publishes the event to relays (loop rule 2 records it
|
||||
// in publishedEventIDs).
|
||||
try deposit(event, into: fixture)
|
||||
#expect(fixture.published.map(\.event.id) == [event.id])
|
||||
|
||||
// Relays then drop and a gateway peer appears. The same event must not
|
||||
// be re-uplinked from this device — the publishedEventIDs backstop in
|
||||
// uplinkViaMesh catches it even though it was never a mesh broadcast.
|
||||
fixture.relaysConnected = false
|
||||
fixture.gatewayPeers = [PeerID(str: "8877665544332211")]
|
||||
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||
#expect(fixture.uplinkSends.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Toggle
|
||||
|
||||
@Test("persists the toggle and reports changes")
|
||||
func togglePersistsAndNotifies() throws {
|
||||
let suite = "GatewayServiceTests-\(UUID().uuidString)"
|
||||
let fixture = Fixture(enabled: false, suite: suite)
|
||||
#expect(!fixture.service.isEnabled)
|
||||
|
||||
fixture.service.setEnabled(true)
|
||||
#expect(fixture.enabledChanges == [true])
|
||||
// Setting the same value again is a no-op.
|
||||
fixture.service.setEnabled(true)
|
||||
#expect(fixture.enabledChanges == [true])
|
||||
|
||||
// A fresh service over the same defaults restores the toggle.
|
||||
let revived = GatewayService(defaults: UserDefaults(suiteName: suite)!)
|
||||
#expect(revived.isEnabled)
|
||||
|
||||
fixture.service.setEnabled(false)
|
||||
#expect(fixture.enabledChanges == [true, false])
|
||||
fixture.defaults.removePersistentDomain(forName: suite)
|
||||
}
|
||||
|
||||
@Test("disabling the toggle drops queued work")
|
||||
func disableClearsQueues() throws {
|
||||
let fixture = Fixture()
|
||||
fixture.relaysConnected = false
|
||||
try deposit(try makeEvent(), into: fixture)
|
||||
#expect(fixture.service.queuedUplinks.count == 1)
|
||||
|
||||
fixture.service.setEnabled(false)
|
||||
#expect(fixture.service.queuedUplinks.isEmpty)
|
||||
|
||||
// Nothing to flush after re-enabling.
|
||||
fixture.service.setEnabled(true)
|
||||
fixture.relaysConnected = true
|
||||
fixture.service.flushQueuedUplinks()
|
||||
#expect(fixture.published.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ public enum MessageType: UInt8 {
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||
|
||||
// Gateway mode: signed Nostr event ferried between a mesh-only peer and
|
||||
// an internet gateway peer.
|
||||
case nostrCarrier = 0x28
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .announce: return "announce"
|
||||
@@ -38,6 +42,7 @@ public enum MessageType: UInt8 {
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
case .boardPost: return "boardPost"
|
||||
case .nostrCarrier: return "nostrCarrier"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user