-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathExtendedNetworkRepository.swift
More file actions
225 lines (192 loc) · 8.43 KB
/
Copy pathExtendedNetworkRepository.swift
File metadata and controls
225 lines (192 loc) · 8.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
import Foundation
/// State of the WoT recompute job. Surfaced by `SafetySettingsView`'s "Recompute network"
/// button. Each step roughly mirrors the Android `DiscoveryState`.
enum WotDiscoveryState: Sendable, Equatable {
case idle
case fetchingFollowLists(fetched: Int, total: Int)
case buildingGraph(processed: Int, total: Int)
case complete(qualifiedCount: Int)
case failed(reason: String)
}
/// Web-of-Trust qualified-set builder. Walks the user's first-degree follows, fetches each
/// follow's kind:3, counts second-degree references, and keeps anyone who appears >=10
/// times. The resulting `qualifiedNetwork` set is consumed by `SafetyFilter` when WoT is on.
actor ExtendedNetworkRepository {
static let shared = ExtendedNetworkRepository()
private static let qualificationThreshold = 10
private static let staleHours = 24
private static let staleDriftRatio = 0.10
private static let followsBatchSize = 200
private static let followsTimeout: TimeInterval = 8
private var pubkey: String?
private var qualified: Set<String> = []
private var firstDegreeCount: Int = 0
private var computedAt: Int = 0
private var inProgress = false
private var stateContinuation: AsyncStream<WotDiscoveryState>.Continuation?
nonisolated let stateStream: AsyncStream<WotDiscoveryState>
private init() {
var cont: AsyncStream<WotDiscoveryState>.Continuation!
self.stateStream = AsyncStream { c in cont = c }
self.stateContinuation = cont
}
// MARK: - Lifecycle
func bind(activePubkey pk: String) {
self.pubkey = pk
loadFromDefaults(pk)
emitState(.idle)
}
func unbind() {
pubkey = nil
qualified = []
firstDegreeCount = 0
computedAt = 0
inProgress = false
emitState(.idle)
}
// MARK: - Public read
func qualifiedSet() -> Set<String> { qualified }
func isStale() -> Bool {
guard let pk = pubkey else { return true }
if computedAt == 0 { return true }
// A computed-but-empty set can't be a real result (recompute always
// includes the non-empty first-degree follows) — it's a corrupt or
// partial cache, and with WoT fail-closed it would hide everything
// forever. Treat as stale so the launch path recomputes and self-heals.
if qualified.isEmpty { return true }
let now = Int(Date().timeIntervalSince1970)
if now - computedAt > Self.staleHours * 3600 { return true }
let currentFollows = FollowsCache.shared.follows(for: pk)
guard firstDegreeCount > 0 else { return false }
let drift = abs(currentFollows.count - firstDegreeCount)
return Double(drift) / Double(firstDegreeCount) > Self.staleDriftRatio
}
func summary() -> (qualifiedCount: Int, computedAt: Int) {
(qualified.count, computedAt)
}
// MARK: - Recompute
func recompute() async {
guard !inProgress else { return }
guard let pk = pubkey else {
emitState(.failed(reason: "No active account"))
return
}
let firstDegree = FollowsCache.shared.follows(for: pk)
guard !firstDegree.isEmpty else {
emitState(.failed(reason: "Follow list is empty"))
return
}
inProgress = true
defer { inProgress = false }
let firstDegreeSet = Set(firstDegree)
emitState(.fetchingFollowLists(fetched: 0, total: firstDegree.count))
// Pick top relays from the user's score board, falling back to the user's read relays
// and finally to a small default set if both are empty.
var relays = await pickRelays(forUser: pk)
if relays.isEmpty { relays = Self.fallbackRelays }
// Chunk follows into batches and parallel-fetch their kind:3.
let chunks = firstDegree.chunked(into: Self.followsBatchSize)
var followEvents: [String: NostrEvent] = [:]
await withTaskGroup(of: [NostrEvent].self) { group in
for chunk in chunks {
let relaysCopy = relays
group.addTask {
let filter = NostrFilter(kinds: [3], authors: chunk, limit: chunk.count * 2)
return await RelayPool.query(
relays: relaysCopy, filter: filter, timeout: Self.followsTimeout
)
}
}
for await batch in group {
for event in batch {
guard event.kind == 3 else { continue }
if let existing = followEvents[event.pubkey], existing.createdAt >= event.createdAt {
continue
}
followEvents[event.pubkey] = event
}
emitState(.fetchingFollowLists(fetched: followEvents.count, total: firstDegree.count))
}
}
emitState(.buildingGraph(processed: 0, total: followEvents.count))
// Count second-degree references: how many of our first-degree follows follow each
// second-degree pubkey.
var counts: [String: Int] = [:]
var processed = 0
for (_, event) in followEvents {
for tag in event.tags where tag.count >= 2 && tag[0] == "p" {
let pk2 = tag[1]
if pk2 == pk { continue }
counts[pk2, default: 0] += 1
}
processed += 1
if processed % 25 == 0 {
emitState(.buildingGraph(processed: processed, total: followEvents.count))
}
}
// Build qualified set: first-degree (always trusted) plus any second-degree pubkey at or
// above the threshold (typically 10 in-network references).
var qual = Set(firstDegree)
for (pk2, count) in counts where count >= Self.qualificationThreshold {
if firstDegreeSet.contains(pk2) { continue }
qual.insert(pk2)
}
qualified = qual
firstDegreeCount = firstDegree.count
computedAt = Int(Date().timeIntervalSince1970)
saveToDefaults(pk)
emitState(.complete(qualifiedCount: qual.count))
await SafetyFilter.shared.rebuildSnapshot()
}
// MARK: - Persistence
private func loadFromDefaults(_ pk: String) {
// All-or-nothing: a partial payload (e.g. `computedAt` present but
// `qualified` missing/empty) would otherwise read as "computed" with
// an empty set — which `isStale` would once have called fresh, locking
// the fail-closed filter into hiding everything permanently. Any
// malformed key resets to the never-computed state so the launch
// recompute self-heals.
guard let data = UserDefaults.standard.data(forKey: Self.cacheKey(pk)),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let arr = json["qualified"] as? [String], !arr.isEmpty,
let first = json["firstDegreeCount"] as? Int,
let computed = json["computedAt"] as? Int, computed > 0 else {
qualified = []
firstDegreeCount = 0
computedAt = 0
return
}
qualified = Set(arr)
firstDegreeCount = first
computedAt = computed
}
private func saveToDefaults(_ pk: String) {
let json: [String: Any] = [
"qualified": Array(qualified),
"firstDegreeCount": firstDegreeCount,
"computedAt": computedAt
]
guard let data = try? JSONSerialization.data(withJSONObject: json) else { return }
UserDefaults.standard.set(data, forKey: Self.cacheKey(pk))
}
static func cacheKey(_ pubkey: String) -> String { "wot_qualified_\(pubkey)" }
// MARK: - Internals
private static let fallbackRelays = RelayDefaults.fallbacks
private func pickRelays(forUser pk: String) async -> [String] {
var seen = Set<String>()
var ordered: [String] = []
if let board = RelayScoreBoard.load(pubkey: pk) {
for relay in board.scoredRelays.prefix(20) where seen.insert(relay.url).inserted {
ordered.append(relay.url)
}
}
let userReads = await RelayListRepository.shared.getReadRelays(pk)
for url in userReads where seen.insert(url).inserted {
ordered.append(url)
}
return ordered
}
private func emitState(_ state: WotDiscoveryState) {
stateContinuation?.yield(state)
}
}