Add mesh diagnostics: /ping, /trace, and topology map

- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
  + origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
  normalization, layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 20:00:55 +02:00
co-authored by Claude Fable 5
parent 7341696280
commit 6056d20a13
21 changed files with 1147 additions and 10 deletions
+22
View File
@@ -59,6 +59,28 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true
}
/// Builds the mesh topology map model from the transport's gossiped
/// graph plus the live nickname table. Unknown nodes (heard about via a
/// neighbor claim but never announced to us) fall back to a short ID.
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
let mesh = chatViewModel.meshService
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
let nicknames = mesh.getPeerNicknames()
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
let isSelf = peerID == snapshot.localPeerID
let label: String
if isSelf {
label = chatViewModel.nickname
} else {
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))"
}
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
}
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
}
func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true
}
+132
View File
@@ -5171,6 +5171,54 @@
}
}
},
"app_info.network.title" : {
"comment" : "Section header for network diagnostics in the app info sheet",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "network"
}
}
}
},
"app_info.network.topology.description" : {
"comment" : "Row description for the mesh topology map",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "map of peers and links learned from mesh announces"
}
}
}
},
"app_info.network.topology.hint" : {
"comment" : "Accessibility hint for the mesh topology row",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "opens the mesh topology map"
}
}
}
},
"app_info.network.topology.title" : {
"comment" : "Row title opening the mesh topology map",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mesh topology"
}
}
}
},
"app_info.privacy.ephemeral.description" : {
"extractionState" : "manual",
"localizations" : {
@@ -15425,6 +15473,18 @@
}
}
},
"content.commands.ping" : {
"comment" : "Description of the /ping command in the suggestions panel",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "measure round-trip time to a mesh peer"
}
}
}
},
"content.commands.slap" : {
"extractionState" : "manual",
"localizations" : {
@@ -15604,6 +15664,18 @@
}
}
},
"content.commands.trace" : {
"comment" : "Description of the /trace command in the suggestions panel",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "estimate the mesh path to a peer"
}
}
}
},
"content.commands.unblock" : {
"extractionState" : "manual",
"localizations" : {
@@ -35194,6 +35266,66 @@
}
}
},
"topology.caption" : {
"comment" : "Caption under the mesh topology map",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted"
}
}
}
},
"topology.empty" : {
"comment" : "Empty state of the mesh topology map",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "no mesh links yet — the map fills in as peer announces arrive"
}
}
}
},
"topology.refresh" : {
"comment" : "Accessibility label of the topology refresh button",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "refresh topology"
}
}
}
},
"topology.summary" : {
"comment" : "Topology map summary: number of peers and links",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$ld peers · %2$ld links"
}
}
}
},
"topology.title" : {
"comment" : "Title of the mesh topology map sheet",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mesh topology"
}
}
}
},
"verification.my_qr.accessibility_label" : {
"extractionState" : "manual",
"localizations" : {
+8 -4
View File
@@ -24,6 +24,8 @@ enum CommandInfo: String, Identifiable {
case who
case favorite = "fav"
case unfavorite = "unfav"
case ping
case trace
var id: String { rawValue }
@@ -31,7 +33,7 @@ enum CommandInfo: String, Identifiable {
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .help, .who:
return nil
@@ -50,16 +52,18 @@ enum CommandInfo: String, Identifiable {
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
// The processor rejects favorites and mesh diagnostics in geohash
// contexts, so only suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return baseCommands
}
return baseCommands + [.favorite, .unfavorite]
return baseCommands + [.favorite, .unfavorite, .ping, .trace]
}
}
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .ping, .pong:
return false
}
}
@@ -51,8 +51,13 @@ 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.
// Ping/pong diagnostics also ride it: probes need the same
// deterministic multi-hop relay as DMs (always relay, jitter,
// no TTL cap) so RTT and hop counts reflect the real path.
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
|| packet.type == MessageType.courierEnvelope.rawValue
|| packet.type == MessageType.ping.rawValue
|| packet.type == MessageType.pong.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,
+159
View File
@@ -67,6 +67,22 @@ final class BLEService: NSObject {
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
// inbound per-peer ping budget so a directed unencrypted probe cannot be
// turned into an amplification primitive. Both are owned by
// collectionsQueue barriers like the other mutable collections.
private struct PendingMeshPing {
let peerID: PeerID
let sentAt: Date
let completion: @MainActor (MeshPingResult?) -> Void
let timeout: DispatchWorkItem
}
private var pendingMeshPings: [Data: PendingMeshPing] = [:]
private var meshPingResponseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerPeer,
window: TransportConfig.meshPingInboundWindowSeconds
)
// 5. Fragment Reassembly (necessary for messages > MTU)
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
@@ -2406,6 +2422,143 @@ extension BLEService {
PeerID(routingData: data)
}
// MARK: - Mesh Diagnostics (/ping, /trace, topology map)
/// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
/// The completion fires exactly once on the main actor: with RTT/hops
/// when the matching pong returns, or nil after the timeout window.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
messageQueue.async { [weak self] in
guard let self,
let recipientData = peerID.toShort().routingData,
let payload = MeshPingPayload(
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
originTTL: self.messageTTL
) else {
Task { @MainActor in completion(nil) }
return
}
let nonce = payload.nonce
let packet = BitchatPacket(
type: MessageType.ping.rawValue,
senderID: self.myPeerIDData,
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload.encode(),
signature: nil,
ttl: self.messageTTL
)
let timeout = DispatchWorkItem { [weak self] in
guard let self else { return }
let expired = self.collectionsQueue.sync(flags: .barrier) {
self.pendingMeshPings.removeValue(forKey: nonce)
}
guard let expired else { return }
Task { @MainActor in expired.completion(nil) }
}
self.collectionsQueue.sync(flags: .barrier) {
self.pendingMeshPings[nonce] = PendingMeshPing(
peerID: PeerID(hexData: recipientData),
sentAt: Date(),
completion: completion,
timeout: timeout
)
}
self.messageQueue.asyncAfter(
deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
execute: timeout
)
self.broadcastPacket(packet)
}
}
/// Answers a ping addressed to us with a pong echoing its nonce; pings
/// addressed elsewhere are left to the generic directed-relay path.
private func handleMeshPing(_ packet: BitchatPacket, from peerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return }
guard let ping = MeshPingPayload.decode(packet.payload) else {
SecureLogger.debug("⚠️ Malformed ping from \(peerID.id.prefix(8))", category: .session)
return
}
let allowed = collectionsQueue.sync(flags: .barrier) {
meshPingResponseLimiter.shouldRespond(to: peerID, now: Date())
}
guard allowed else {
if logRateLimiter.shouldLog(key: "ping-limit:\(peerID.id)") {
SecureLogger.warning("🚫 Rate-limiting pings from \(peerID.id.prefix(8))", category: .security)
}
return
}
guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
let reply = BitchatPacket(
type: MessageType.pong.rawValue,
senderID: myPeerIDData,
recipientID: packet.senderID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: pong.encode(),
signature: nil,
ttl: messageTTL
)
broadcastPacket(reply)
}
/// Resolves a pong against its outstanding probe. The unguessable echoed
/// nonce plus the sender check bind the reply to the probed peer; hops
/// come from the pong's TTL decrements on the return path.
private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return }
guard let pong = MeshPingPayload.decode(packet.payload) else { return }
let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
return pendingMeshPings.removeValue(forKey: pong.nonce)
}
guard let pending else { return }
pending.timeout.cancel()
let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
let result = MeshPingResult(
rttMs: max(0, rttMs),
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
)
Task { @MainActor in pending.completion(result) }
}
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
/// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
refreshLocalTopology()
if let route = computeRoute(to: peerID) {
return route.compactMap { PeerID(routingData: $0) }
}
// Confirmed claims can lag a brand-new link (the peer's next announce
// hasn't arrived yet); a live direct connection is still a known path.
return isPeerConnected(peerID) ? [] : nil
}
/// Mesh graph for the topology map. Edges are advisory: announces cap
/// neighbor lists at 10, so an edge claimed by either endpoint counts.
func currentMeshTopology() -> MeshTopologySnapshot? {
refreshLocalTopology()
let claims = meshTopology.adjacencySnapshot()
var nodes = Set<PeerID>()
var edges = Set<MeshTopologyEdge>()
for (source, neighbors) in claims {
guard let sourcePeer = PeerID(routingData: source) else { continue }
nodes.insert(sourcePeer)
for neighborData in neighbors {
guard let neighborPeer = PeerID(routingData: neighborData),
neighborPeer != sourcePeer else { continue }
nodes.insert(neighborPeer)
edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
}
}
nodes.insert(myPeerID)
return MeshTopologySnapshot(
localPeerID: myPeerID,
nodes: nodes.sorted(),
edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
)
}
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
let plan = BLERouteForwardingPolicy.plan(
@@ -3203,6 +3356,12 @@ extension BLEService {
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .ping:
handleMeshPing(packet, from: senderID)
case .pong:
handleMeshPong(packet, from: senderID)
case .leave:
handleLeave(packet, from: senderID)
+77
View File
@@ -49,6 +49,9 @@ protocol CommandContextProvider: AnyObject {
// MARK: - System Messages
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
func addPublicSystemMessage(_ content: String)
/// Routes deferred command output (e.g. an async /ping result) into the
/// conversation where the user typed the command.
func addCommandOutput(_ content: String)
// MARK: - Favorites
/// Toggles the favorite via the unified peer flow, which persists by the
@@ -106,6 +109,12 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
case "/ping":
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
return handlePing(args)
case "/trace":
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
return handleTrace(args)
case "/help":
return .success(message: Self.helpText)
default:
@@ -125,6 +134,8 @@ final class CommandProcessor {
/slap @name — slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name — favorites (mesh only)
/ping @name — measure round-trip time (mesh only)
/trace @name — estimated mesh path (mesh only)
/help — this list
"""
@@ -331,6 +342,72 @@ final class CommandProcessor {
return .error(message: "cannot unblock \(nickname): not found")
}
// MARK: - Mesh Diagnostics
private enum MeshPeerResolution {
case resolved(peerID: PeerID, nickname: String)
case failed(CommandResult)
}
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
/// rejected diagnostics measure the BLE mesh, not Nostr.
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .failed(.error(message: "usage: /\(command) <nickname>"))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
!peerID.isGeoDM, !peerID.isGeoChat else {
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
}
return .resolved(peerID: peerID, nickname: nickname)
}
private func handlePing(_ args: String) -> CommandResult {
let target: (peerID: PeerID, nickname: String)
switch resolveMeshPeer(args, command: "ping") {
case .resolved(let peerID, let nickname): target = (peerID, nickname)
case .failed(let result): return result
}
let nickname = target.nickname
let currentProvider = contextProvider
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
let provider = currentProvider
guard let result else {
provider?.addCommandOutput("no reply from \(nickname)")
return
}
let hopText: String = result.hops.map { hops in
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
} ?? ""
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)")
}
return .success(message: "pinging \(nickname)")
}
private func handleTrace(_ args: String) -> CommandResult {
let target: (peerID: PeerID, nickname: String)
switch resolveMeshPeer(args, command: "trace") {
case .resolved(let peerID, let nickname): target = (peerID, nickname)
case .failed(let result): return result
}
guard let mesh = meshService,
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
return .success(message: "no known path to \(target.nickname)")
}
// Graph-derived from gossiped neighbor claims, not route-recorded
// present it as an estimate.
let hopNames = intermediates.map { hop in
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))"
}
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: "")
let hops = intermediates.count + 1
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed
guard !targetName.isEmpty else {
@@ -34,6 +34,13 @@ final class MeshTopologyTracker {
}
}
/// Raw directed neighbor claims, for diagnostics (topology map, /trace).
/// Callers treat the claims as advisory: announces cap `directNeighbors`
/// at 10, so an edge may be claimed by only one of its endpoints.
func adjacencySnapshot() -> [Data: Set<Data>] {
queue.sync { claims }
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
+53
View File
@@ -31,6 +31,40 @@ struct TransportPeerSnapshot: Equatable, Hashable {
}
}
/// Outcome of a `/ping` probe over the mesh.
struct MeshPingResult: Equatable {
/// Round-trip time in milliseconds.
let rttMs: Int
/// Total hops to the peer (1 = directly connected), derived from the
/// pong's TTL decrements; nil when the reply carried inconsistent TTLs.
let hops: Int?
}
/// Undirected mesh link between two peers, normalized so `(a, b)` and
/// `(b, a)` collapse to one edge.
struct MeshTopologyEdge: Hashable {
let a: PeerID
let b: PeerID
init(_ first: PeerID, _ second: PeerID) {
if first < second {
a = first
b = second
} else {
a = second
b = first
}
}
}
/// Point-in-time view of the mesh graph learned from gossiped announces
/// (each announce carries up to 10 `directNeighbors`).
struct MeshTopologySnapshot: Equatable {
let localPeerID: PeerID
let nodes: [PeerID]
let edges: [MeshTopologyEdge]
}
enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@@ -124,6 +158,17 @@ protocol Transport: AnyObject {
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// Mesh diagnostics (optional for transports). Defaults are inert so
// queue-backed transports (e.g. NostrTransport) stay untouched.
/// Sends a directed ping probe; the completion fires exactly once on the
/// main actor with the measured result, or nil on timeout/unsupported.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map; nil when unsupported.
func currentMeshTopology() -> MeshTopologySnapshot?
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -154,6 +199,14 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
// Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
Task { @MainActor in completion(nil) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
+5
View File
@@ -16,6 +16,11 @@ enum TransportConfig {
static let bleFragmentRelayTtlCap: UInt8 = 7
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
// Mesh diagnostics (/ping)
static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
static let meshPingInboundMaxPerPeer: Int = 5 // Inbound ping budget per peer...
static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification)
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
+3
View File
@@ -23,6 +23,9 @@ struct SyncTypeFlags: OptionSet {
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
// Ping/pong are ephemeral directed probes; replaying them via gossip
// sync would only produce stale, unanswerable echoes.
case .ping, .pong: return nil
}
}
+4 -2
View File
@@ -1514,9 +1514,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Command output belongs in the conversation where the user typed the
/// command; the public timeline is invisible while a DM is open. The DM
/// selection is read *after* processing so commands that switch chats
/// (`/msg`) print into the conversation they just opened.
/// (`/msg`) print into the conversation they just opened. Internal (not
/// private) because CommandProcessor routes deferred output (async /ping
/// results) through it via CommandContextProvider.
@MainActor
private func addCommandOutput(_ content: String) {
func addCommandOutput(_ content: String) {
if let peerID = selectedPrivateChatPeer {
addLocalPrivateSystemMessage(content, to: peerID)
} else {
+45
View File
@@ -5,6 +5,11 @@ struct AppInfoView: View {
@ThemedPalette private var palette
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
/// Supplies the mesh topology map data. Nil (previews, missing wiring)
/// hides the topology row entirely.
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
@State private var showTopology = false
private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix
}
@@ -75,6 +80,15 @@ struct AppInfoView: View {
]
}
enum Network {
static let title: LocalizedStringKey = "app_info.network.title"
static let topology = AppInfoFeatureInfo(
icon: "point.3.connected.trianglepath.dotted",
title: "app_info.network.topology.title",
description: "app_info.network.topology.description"
)
}
enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo(
@@ -129,6 +143,11 @@ struct AppInfoView: View {
.themedSheetBackground()
}
.frame(width: 600, height: 700)
.sheet(isPresented: $showTopology) {
if let topologyProvider {
MeshTopologyView(provider: topologyProvider)
}
}
#else
NavigationView {
ScrollView {
@@ -143,6 +162,11 @@ struct AppInfoView: View {
}
}
}
.sheet(isPresented: $showTopology) {
if let topologyProvider {
MeshTopologyView(provider: topologyProvider)
}
}
#endif
}
@@ -238,6 +262,27 @@ struct AppInfoView: View {
}
}
// Network diagnostics
if topologyProvider != nil {
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Network.title)
Button {
showTopology = true
} label: {
HStack(spacing: 0) {
FeatureRow(info: Strings.Network.topology)
Image(systemName: "chevron.right")
.font(.bitchatSystem(size: 12))
.foregroundColor(secondaryTextColor)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(Text("app_info.network.topology.hint"))
}
}
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title)
+1 -1
View File
@@ -149,7 +149,7 @@ struct ContentView: View {
#endif
}
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
AppInfoView()
AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() })
}
.sheet(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
+217
View File
@@ -0,0 +1,217 @@
//
// MeshTopologyView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// Display model for the mesh topology map: nodes are known mesh peers,
/// edges are gossiped `directNeighbors` claims. Built on the main actor from
/// a `MeshTopologySnapshot` plus the current nickname table.
struct MeshTopologyDisplayModel {
struct Node: Identifiable, Equatable {
let id: String
let label: String
let isSelf: Bool
}
let nodes: [Node]
/// Pairs of `Node.id`; every id is present in `nodes`.
let edges: [(String, String)]
static let empty = MeshTopologyDisplayModel(nodes: [], edges: [])
}
/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in
/// the center), drawn with Canvas so it stays cheap at any peer count.
struct MeshTopologyView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appTheme) private var appTheme
@ThemedPalette private var palette
/// Fetches a fresh model; called on appear and on manual refresh.
let provider: @MainActor () -> MeshTopologyDisplayModel
@State private var model: MeshTopologyDisplayModel = .empty
var body: some View {
#if os(macOS)
VStack(spacing: 0) {
HStack {
Text("topology.title")
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(palette.primary)
Spacer()
refreshButton
Button("app_info.done") {
dismiss()
}
.buttonStyle(.plain)
.foregroundColor(palette.primary)
}
.padding()
.themedSurface(opacity: 0.95)
content
}
.frame(width: 500, height: 520)
.themedSheetBackground()
#else
NavigationView {
content
.themedSheetBackground()
.navigationTitle(Text("topology.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
refreshButton
}
ToolbarItem(placement: .navigationBarTrailing) {
SheetCloseButton { dismiss() }
.foregroundColor(palette.primary)
}
}
}
#endif
}
private var refreshButton: some View {
Button {
model = provider()
} label: {
Image(systemName: "arrow.clockwise")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("topology.refresh"))
}
@ViewBuilder
private var content: some View {
VStack(spacing: 12) {
if model.nodes.count <= 1 {
Spacer()
Text("topology.empty")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Spacer()
} else {
graphCanvas
.padding(.horizontal, 8)
}
VStack(spacing: 4) {
Text(summaryText)
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
Text("topology.caption")
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal)
.padding(.bottom, 16)
}
.onAppear { model = provider() }
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(summaryText))
}
private var summaryText: String {
String(
format: String(
localized: "topology.summary",
comment: "Topology map summary: number of peers and links"
),
locale: .current,
model.nodes.count,
model.edges.count
)
}
private var graphCanvas: some View {
Canvas { context, size in
let positions = Self.layout(nodes: model.nodes, in: size)
let fontDesign = appTheme.bodyFontDesign
// Edges first so nodes draw on top.
for (fromID, toID) in model.edges {
guard let from = positions[fromID], let to = positions[toID] else { continue }
var path = Path()
path.move(to: from)
path.addLine(to: to)
context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1)
}
for node in model.nodes {
guard let center = positions[node.id] else { continue }
let radius: CGFloat = node.isSelf ? 7 : 5
let dot = Path(ellipseIn: CGRect(
x: center.x - radius,
y: center.y - radius,
width: radius * 2,
height: radius * 2
))
context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary))
if node.isSelf {
let ring = Path(ellipseIn: CGRect(
x: center.x - radius - 3,
y: center.y - radius - 3,
width: (radius + 3) * 2,
height: (radius + 3) * 2
))
context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1)
}
context.draw(
Text(node.label)
.font(.system(size: 10, design: fontDesign))
.foregroundColor(node.isSelf ? palette.accent : palette.secondary),
at: CGPoint(x: center.x, y: center.y + radius + 4),
anchor: .top
)
}
}
.accessibilityHidden(true) // The combined summary label narrates the graph.
}
/// Circular layout: self in the center, everyone else evenly spaced on a
/// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle.
static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] {
let center = CGPoint(x: size.width / 2, y: size.height / 2)
// Leave room for the label row under each ring node.
let radius = max(20, min(size.width, size.height) / 2 - 36)
var positions: [String: CGPoint] = [:]
let ringNodes = nodes.filter { !$0.isSelf }
for node in nodes where node.isSelf {
positions[node.id] = center
}
for (index, node) in ringNodes.enumerated() {
let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2
positions[node.id] = CGPoint(
x: center.x + radius * cos(angle),
y: center.y + radius * sin(angle)
)
}
return positions
}
}
#Preview("Topology") {
MeshTopologyView(provider: {
MeshTopologyDisplayModel(
nodes: [
.init(id: "self", label: "me", isSelf: true),
.init(id: "a", label: "alice", isSelf: false),
.init(id: "b", label: "bob", isSelf: false),
.init(id: "c", label: "carol", isSelf: false)
],
edges: [("self", "a"), ("a", "b"), ("self", "c")]
)
})
}
+5
View File
@@ -435,6 +435,7 @@ private final class MockCommandContextProvider: CommandContextProvider {
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
private(set) var commandOutputs: [String] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
@@ -485,6 +486,10 @@ private final class MockCommandContextProvider: CommandContextProvider {
publicSystemMessages.append(content)
}
func addCommandOutput(_ content: String) {
commandOutputs.append(content)
}
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
+21
View File
@@ -196,6 +196,27 @@ final class MockTransport: Transport {
return courierSendResult
}
// MARK: - Mesh Diagnostics
private(set) var sentMeshPings: [PeerID] = []
var meshPingResult: MeshPingResult?
var meshPaths: [PeerID: [PeerID]] = [:]
var meshTopologySnapshot: MeshTopologySnapshot?
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
sentMeshPings.append(peerID)
let result = meshPingResult
Task { @MainActor in completion(result) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
meshPaths[peerID]
}
func currentMeshTopology() -> MeshTopologySnapshot? {
meshTopologySnapshot
}
// MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions
@@ -0,0 +1,247 @@
//
// MeshDiagnosticsTests.swift
// bitchatTests
//
// Tests for /ping and /trace command handling and the topology snapshot
// types backing the mesh topology map.
// This is free and unencumbered software released into the public domain.
//
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@Suite(.serialized)
struct MeshDiagnosticsTests {
// MARK: - Helpers
@MainActor
private func makeProcessor(
context: DiagnosticsMockContext,
transport: MockTransport
) -> CommandProcessor {
CommandProcessor(
contextProvider: context,
meshService: transport,
identityManager: MockIdentityManager(MockKeychain())
)
}
/// Waits for the async ping completion (MainActor hop) to land.
@MainActor
private func waitForCommandOutput(_ context: DiagnosticsMockContext) async {
for _ in 0..<100 {
if !context.commandOutputs.isEmpty { return }
await Task.yield()
try? await Task.sleep(nanoseconds: 5_000_000)
}
}
// MARK: - /ping
@MainActor
@Test func pingWithoutArgumentShowsUsage() {
let context = DiagnosticsMockContext()
let processor = makeProcessor(context: context, transport: MockTransport())
let result = processor.process("/ping")
switch result {
case .error(let message):
#expect(message == "usage: /ping <nickname>")
default:
Issue.record("Expected usage error")
}
}
@MainActor
@Test func pingUnknownPeerFails() {
let context = DiagnosticsMockContext()
let processor = makeProcessor(context: context, transport: MockTransport())
let result = processor.process("/ping @ghost")
switch result {
case .error(let message):
#expect(message == "cannot ping ghost: not found on mesh")
default:
Issue.record("Expected error result")
}
}
@MainActor
@Test func pingGeoDMPeerIsRejected() {
let context = DiagnosticsMockContext()
context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899")
let processor = makeProcessor(context: context, transport: MockTransport())
let result = processor.process("/ping @alice")
switch result {
case .error(let message):
#expect(message == "cannot ping alice: not found on mesh")
default:
Issue.record("Expected error for geo peer")
}
}
@MainActor
@Test func pingSuccessReportsRttAndHops() async {
let context = DiagnosticsMockContext()
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["alice"] = peerID
let transport = MockTransport()
transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
let processor = makeProcessor(context: context, transport: transport)
let result = processor.process("/ping @alice")
switch result {
case .success(let message):
#expect(message == "pinging alice…")
default:
Issue.record("Expected immediate 'pinging' feedback")
}
#expect(transport.sentMeshPings == [peerID])
await waitForCommandOutput(context)
#expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"])
}
@MainActor
@Test func pingDirectPeerReportsSingleHop() async {
let context = DiagnosticsMockContext()
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
let transport = MockTransport()
transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1)
let processor = makeProcessor(context: context, transport: transport)
_ = processor.process("/ping alice")
await waitForCommandOutput(context)
#expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"])
}
@MainActor
@Test func pingTimeoutReportsNoReply() async {
let context = DiagnosticsMockContext()
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
let transport = MockTransport()
transport.meshPingResult = nil
let processor = makeProcessor(context: context, transport: transport)
_ = processor.process("/ping @alice")
await waitForCommandOutput(context)
#expect(context.commandOutputs == ["no reply from alice"])
}
// MARK: - /trace
@MainActor
@Test func traceDirectPeerShowsOneHop() {
let context = DiagnosticsMockContext()
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
context.nicknameToPeerID["bob"] = bob
let transport = MockTransport()
transport.meshPaths[bob] = []
let processor = makeProcessor(context: context, transport: transport)
let result = processor.process("/trace @bob")
switch result {
case .success(let message):
#expect(message == "estimated path: you → bob (1 hop)")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func traceMultiHopUsesNicknamesWithShortIDFallback() {
let context = DiagnosticsMockContext()
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
let alice = PeerID(str: "a11cea11cea11cea")
let unknown = PeerID(str: "dead00beef001234")
context.nicknameToPeerID["bob"] = bob
let transport = MockTransport()
transport.peerNicknames = [alice: "alice"]
transport.meshPaths[bob] = [alice, unknown]
let processor = makeProcessor(context: context, transport: transport)
let result = processor.process("/trace bob")
switch result {
case .success(let message):
#expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func traceWithoutPathReportsNoKnownPath() {
let context = DiagnosticsMockContext()
context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0")
let processor = makeProcessor(context: context, transport: MockTransport())
let result = processor.process("/trace @bob")
switch result {
case .success(let message):
#expect(message == "no known path to bob")
default:
Issue.record("Expected success result")
}
}
// MARK: - Topology snapshot types
@Test func topologyEdgeNormalizesEndpointOrder() {
let a = PeerID(str: "aaaa000000000000")
let b = PeerID(str: "bbbb000000000000")
#expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a))
#expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1)
}
@Test func topologyLayoutPlacesSelfInCenter() {
let nodes: [MeshTopologyDisplayModel.Node] = [
.init(id: "self", label: "me", isSelf: true),
.init(id: "a", label: "alice", isSelf: false),
.init(id: "b", label: "bob", isSelf: false)
]
let size = CGSize(width: 200, height: 200)
let positions = MeshTopologyView.layout(nodes: nodes, in: size)
#expect(positions["self"] == CGPoint(x: 100, y: 100))
#expect(positions.count == 3)
// Ring nodes sit on the same radius around the center.
let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100)
let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100)
#expect(abs(radiusA - radiusB) < 0.001)
#expect(radiusA > 0)
}
}
/// Minimal CommandContextProvider for diagnostics tests; records deferred
/// command output so async /ping results can be asserted.
@MainActor
private final class DiagnosticsMockContext: CommandContextProvider {
var nickname: String = "tester"
var activeChannel: ChannelID = .mesh
var selectedPrivateChatPeer: PeerID?
var blockedUsers: Set<String> = []
let idBridge = NostrIdentityBridge(keychain: MockKeychain())
var nicknameToPeerID: [String: PeerID] = [:]
private(set) var commandOutputs: [String] = []
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
nicknameToPeerID[nickname]
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] }
func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil }
func startPrivateChat(with peerID: PeerID) {}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {}
func clearCurrentPublicTimeline() {}
func clearPrivateChat(_ peerID: PeerID) {}
func sendPublicRaw(_ content: String) {}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
func addPublicSystemMessage(_ content: String) {}
func toggleFavorite(peerID: PeerID) {}
func addCommandOutput(_ content: String) {
commandOutputs.append(content)
}
}
@@ -0,0 +1,57 @@
//
// MeshPingPayload.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
/// Wire payload shared by the `ping` (0x26) and `pong` (0x27) message types.
///
/// Layout (9 bytes):
/// - 8 bytes: random nonce (a pong echoes the nonce of the ping it answers)
/// - 1 byte: origin TTL the TTL the packet was launched with, so the
/// receiver can compute the hop count as `originTTL - receivedTTL`.
///
/// Both directions are unencrypted and unsigned: the payload carries no
/// private data, and the unguessable nonce already binds a pong to a probe
/// the local device actually sent.
public struct MeshPingPayload: Equatable {
public static let nonceLength = 8
private static let encodedLength = nonceLength + 1
public let nonce: Data
public let originTTL: UInt8
public init?(nonce: Data, originTTL: UInt8) {
guard nonce.count == Self.nonceLength else { return nil }
self.nonce = nonce
self.originTTL = originTTL
}
public func encode() -> Data {
var data = Data(capacity: Self.encodedLength)
data.append(nonce)
data.append(originTTL)
return data
}
/// Accepts payloads with trailing bytes so future revisions can extend
/// the format without breaking older clients.
public static func decode(_ data: Data) -> MeshPingPayload? {
guard data.count >= encodedLength else { return nil }
let nonce = Data(data.prefix(nonceLength))
let originTTL = data[data.index(data.startIndex, offsetBy: nonceLength)]
return MeshPingPayload(nonce: nonce, originTTL: originTTL)
}
/// Number of links a packet crossed, derived from TTL decrements plus the
/// final delivery link (a directly connected peer is 1 hop away).
/// Returns nil when the TTLs are inconsistent (received above origin).
public static func hopCount(originTTL: UInt8, receivedTTL: UInt8) -> Int? {
guard originTTL >= receivedTTL else { return nil }
return Int(originTTL - receivedTTL) + 1
}
}
@@ -24,7 +24,11 @@ public enum MessageType: UInt8 {
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
// Mesh diagnostics (0x23-0x25 and 0x28 are reserved by other features)
case ping = 0x26 // Directed echo request (nonce + origin TTL)
case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL)
public var description: String {
switch self {
case .announce: return "announce"
@@ -36,6 +40,8 @@ public enum MessageType: UInt8 {
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
case .ping: return "ping"
case .pong: return "pong"
}
}
}
@@ -0,0 +1,70 @@
//
// MeshPingPayloadTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct MeshPingPayloadTests {
@Test func encodeDecodeRoundTrip() throws {
let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF])
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
let encoded = payload.encode()
#expect(encoded.count == 9)
#expect(encoded.prefix(8) == nonce)
#expect(encoded.last == 7)
let decoded = try #require(MeshPingPayload.decode(encoded))
#expect(decoded == payload)
}
@Test func decodeToleratesTrailingBytes() throws {
let nonce = Data(repeating: 0x42, count: 8)
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3))
var extended = payload.encode()
extended.append(contentsOf: [0xDE, 0xAD])
let decoded = try #require(MeshPingPayload.decode(extended))
#expect(decoded == payload)
}
@Test func decodeRespectsSliceIndices() throws {
// Data slices keep their parent's indices; decoding must not assume
// startIndex == 0.
let nonce = Data(repeating: 0x11, count: 8)
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5))
let framed = Data([0x00, 0x00]) + payload.encode()
let slice = framed.dropFirst(2)
let decoded = try #require(MeshPingPayload.decode(slice))
#expect(decoded == payload)
}
@Test func rejectsTruncatedPayload() {
#expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil)
#expect(MeshPingPayload.decode(Data()) == nil)
}
@Test func rejectsWrongNonceLength() {
#expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil)
#expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil)
}
@Test func hopCountMath() {
// Direct link: no TTL decrement, one hop.
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1)
// One relay in between: two hops.
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2)
// Full TTL consumed.
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7)
// Inconsistent TTLs (received above origin) are rejected.
#expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil)
}
}