1use std::collections::{BTreeMap, HashMap, HashSet};
7
8use serde::{Deserialize, Serialize};
9
10use crate::nostr::{HeartbeatContent, IsolationLevel, PodSpec, ProviderOfferContent};
11use crate::reputation::{score_provider, CompletionReceipt, ConsumerProfile, SybilHeuristics};
12use crate::stake::{stake_rank, StakeStatus};
13
14pub const SNAPSHOT_VERSION: u8 = 1;
18
19pub const RECEIPT_WINDOW_SECS: u64 = 30 * 24 * 3600;
22
23pub struct AggregatorInput {
29 pub offers: Vec<ProviderOfferContent>,
30 pub heartbeats: Vec<HeartbeatContent>,
31 pub receipts: Vec<CompletionReceipt>,
32 pub consumers: HashMap<String, ConsumerProfile>,
33 pub stake_statuses: HashMap<String, StakeStatus>,
38 pub anchor_providers: HashSet<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Snapshot {
45 pub version: u8,
46 pub generated_at: u64,
47 pub receipt_window_secs: u64,
50 pub providers: Vec<ProviderSummary>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ProviderSummary {
57 pub npub: String,
58 pub hostname: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
63 pub jurisdiction: Option<String>,
64 pub score: f32,
67 pub last_seen_unix: Option<u64>,
69 #[serde(skip_serializing_if = "Option::is_none")]
72 pub stake: Option<StakeSummary>,
73 pub anchor: bool,
74 pub specs: Vec<PodSpec>,
75 pub isolation_level: IsolationLevel,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct StakeSummary {
81 pub effective_sats: u64,
82 pub locktime_unix: u64,
83 pub rank: f64,
85}
86
87pub fn compute_snapshot(input: &AggregatorInput, now: u64) -> Snapshot {
89 let heuristics = SybilHeuristics::default();
90 let receipt_floor = now.saturating_sub(RECEIPT_WINDOW_SECS);
91
92 let windowed: Vec<&CompletionReceipt> = input
95 .receipts
96 .iter()
97 .filter(|r| r.completed_at >= receipt_floor)
98 .collect();
99
100 let mut last_seen: HashMap<&str, u64> = HashMap::new();
102 for hb in &input.heartbeats {
103 let cur = last_seen.entry(hb.provider_npub.as_str()).or_insert(0);
104 if hb.timestamp > *cur {
105 *cur = hb.timestamp;
106 }
107 }
108
109 let mut by_npub: BTreeMap<&str, &ProviderOfferContent> = BTreeMap::new();
112 for offer in &input.offers {
113 by_npub.insert(offer.provider_npub.as_str(), offer);
114 }
115
116 let mut providers = Vec::with_capacity(by_npub.len());
117 for (npub, offer) in by_npub {
118 let receipts_owned: Vec<CompletionReceipt> =
125 windowed.iter().map(|r| (*r).clone()).collect();
126 let score = score_provider(
127 npub,
128 &receipts_owned,
129 &input.consumers,
130 now,
131 &heuristics,
132 |_| true,
133 |_| true,
134 );
135
136 let stake = input.stake_statuses.get(npub).and_then(|s| match s {
137 StakeStatus::Valid {
138 effective_sats,
139 locktime_unix,
140 } => Some(StakeSummary {
141 effective_sats: *effective_sats,
142 locktime_unix: *locktime_unix,
143 rank: stake_rank(*effective_sats, *locktime_unix, now),
144 }),
145 _ => None,
146 });
147
148 providers.push(ProviderSummary {
149 npub: npub.to_string(),
150 hostname: offer.hostname.clone(),
151 jurisdiction: offer.location.clone(),
152 score,
153 last_seen_unix: last_seen.get(npub).copied(),
154 stake,
155 anchor: input.anchor_providers.contains(npub),
156 specs: offer.specs.clone(),
157 isolation_level: offer.isolation_level.clone(),
158 });
159 }
160
161 Snapshot {
162 version: SNAPSHOT_VERSION,
163 generated_at: now,
164 receipt_window_secs: RECEIPT_WINDOW_SECS,
165 providers,
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::nostr::{CapacityInfo, IsolationLevel, SCHEMA_VERSION};
173 use crate::reputation::PaymentProof;
174 use crate::stake::StakeStatus;
175
176 fn offer(npub: &str, hostname: &str, location: Option<&str>) -> ProviderOfferContent {
177 ProviderOfferContent {
178 provider_npub: npub.to_string(),
179 hostname: hostname.to_string(),
180 location: location.map(|s| s.to_string()),
181 capabilities: vec!["lxc".to_string()],
182 specs: vec![],
183 whitelisted_mints: vec!["https://mint.example".to_string()],
184 uptime_percent: 99.0,
185 total_jobs_completed: 5,
186 api_endpoint: None,
187 version: SCHEMA_VERSION,
188 isolation_level: IsolationLevel::SharedKernel,
189 stake_proof: None,
190 }
191 }
192
193 fn heartbeat(npub: &str, ts: u64) -> HeartbeatContent {
194 HeartbeatContent {
195 provider_npub: npub.to_string(),
196 timestamp: ts,
197 active_workloads: 0,
198 available_capacity: CapacityInfo {
199 cpu_available: 0,
200 memory_mb_available: 0,
201 storage_gb_available: 0,
202 },
203 version: SCHEMA_VERSION,
204 }
205 }
206
207 fn receipt(provider: &str, consumer: &str, completed_at: u64) -> CompletionReceipt {
208 CompletionReceipt {
209 lease_id: format!("l-{}-{}-{}", provider, consumer, completed_at),
210 provider_npub: provider.to_string(),
211 consumer_npub: consumer.to_string(),
212 duration_paid: 3600,
213 duration_delivered: 3600,
214 success_flag: 1.0,
215 payment_proof: PaymentProof {
216 mint_url: "https://mint.example".to_string(),
217 swap_response_signature: "sig".to_string(),
218 },
219 version: 1,
220 consumer_signature: Some("c".to_string()),
221 provider_co_signature: Some("p".to_string()),
222 completed_at,
223 }
224 }
225
226 #[test]
227 fn snapshot_is_byte_identical_when_inputs_match() {
228 let now = 1_700_000_000;
229 let input = AggregatorInput {
230 offers: vec![
231 offer("npubB", "host-b", None),
232 offer("npubA", "host-a", Some("BER")),
233 ],
234 heartbeats: vec![heartbeat("npubA", now - 60), heartbeat("npubA", now - 120)],
235 receipts: vec![],
236 consumers: HashMap::new(),
237 stake_statuses: HashMap::new(),
238 anchor_providers: HashSet::new(),
239 };
240 let snap_a = compute_snapshot(&input, now);
241 let snap_b = compute_snapshot(&input, now);
242 let json_a = serde_json::to_string(&snap_a).unwrap();
243 let json_b = serde_json::to_string(&snap_b).unwrap();
244 assert_eq!(json_a, json_b);
245 }
246
247 #[test]
248 fn providers_are_sorted_by_npub_for_reproducibility() {
249 let now = 1_700_000_000;
250 let input = AggregatorInput {
251 offers: vec![
252 offer("npubZ", "z", None),
253 offer("npubA", "a", None),
254 offer("npubM", "m", None),
255 ],
256 heartbeats: vec![],
257 receipts: vec![],
258 consumers: HashMap::new(),
259 stake_statuses: HashMap::new(),
260 anchor_providers: HashSet::new(),
261 };
262 let snap = compute_snapshot(&input, now);
263 let order: Vec<_> = snap.providers.iter().map(|p| p.npub.as_str()).collect();
264 assert_eq!(order, vec!["npubA", "npubM", "npubZ"]);
265 }
266
267 #[test]
268 fn jurisdiction_is_only_emitted_if_offer_opted_in() {
269 let now = 1_700_000_000;
270 let input = AggregatorInput {
271 offers: vec![
272 offer("npubA", "a", Some("BER")),
273 offer("npubB", "b", None), ],
275 heartbeats: vec![],
276 receipts: vec![],
277 consumers: HashMap::new(),
278 stake_statuses: HashMap::new(),
279 anchor_providers: HashSet::new(),
280 };
281 let snap = compute_snapshot(&input, now);
282 let by: HashMap<_, _> = snap
283 .providers
284 .iter()
285 .map(|p| (p.npub.as_str(), p))
286 .collect();
287 assert_eq!(by["npubA"].jurisdiction.as_deref(), Some("BER"));
288 assert!(by["npubB"].jurisdiction.is_none());
289 }
290
291 #[test]
292 fn old_receipts_are_aged_out_of_window() {
293 let now = 1_700_000_000;
294 let in_window = receipt("P", "C", now - 7 * 24 * 3600); let out_of_window = receipt("P", "C", now - 60 * 24 * 3600); let mut consumers = HashMap::new();
297 consumers.insert(
298 "C".to_string(),
299 ConsumerProfile {
300 npub: "C".to_string(),
301 first_seen: now - 365 * 24 * 3600, },
303 );
304 let input = AggregatorInput {
305 offers: vec![offer("P", "p", None)],
306 heartbeats: vec![],
307 receipts: vec![in_window, out_of_window],
308 consumers,
309 stake_statuses: HashMap::new(),
310 anchor_providers: HashSet::new(),
311 };
312 let snap = compute_snapshot(&input, now);
313 assert!((snap.providers[0].score - 0.20).abs() < 1e-6);
316 }
317
318 #[test]
319 fn anchor_providers_are_flagged() {
320 let now = 1_700_000_000;
321 let mut anchors = HashSet::new();
322 anchors.insert("npubAnchor".to_string());
323 let input = AggregatorInput {
324 offers: vec![
325 offer("npubAnchor", "anchor", None),
326 offer("npubOther", "other", None),
327 ],
328 heartbeats: vec![],
329 receipts: vec![],
330 consumers: HashMap::new(),
331 stake_statuses: HashMap::new(),
332 anchor_providers: anchors,
333 };
334 let snap = compute_snapshot(&input, now);
335 let by: HashMap<_, _> = snap
336 .providers
337 .iter()
338 .map(|p| (p.npub.as_str(), p))
339 .collect();
340 assert!(by["npubAnchor"].anchor);
341 assert!(!by["npubOther"].anchor);
342 }
343
344 #[test]
345 fn stake_summary_only_emitted_when_status_is_valid() {
346 let now = 1_700_000_000;
347 let mut stake_statuses = HashMap::new();
348 stake_statuses.insert(
349 "npubStaked".to_string(),
350 StakeStatus::Valid {
351 effective_sats: 100_000,
352 locktime_unix: now + 30 * 24 * 3600,
353 },
354 );
355 stake_statuses.insert("npubSpent".to_string(), StakeStatus::Spent);
356 let input = AggregatorInput {
357 offers: vec![
358 offer("npubStaked", "s", None),
359 offer("npubSpent", "x", None),
360 ],
361 heartbeats: vec![],
362 receipts: vec![],
363 consumers: HashMap::new(),
364 stake_statuses,
365 anchor_providers: HashSet::new(),
366 };
367 let snap = compute_snapshot(&input, now);
368 let by: HashMap<_, _> = snap
369 .providers
370 .iter()
371 .map(|p| (p.npub.as_str(), p))
372 .collect();
373 assert!(by["npubStaked"].stake.is_some());
374 assert!(by["npubSpent"].stake.is_none());
375 assert!(by["npubStaked"].stake.as_ref().unwrap().rank > 0.0);
377 }
378
379 #[test]
380 fn last_seen_picks_max_timestamp() {
381 let now = 1_700_000_000;
382 let input = AggregatorInput {
383 offers: vec![offer("P", "p", None)],
384 heartbeats: vec![
385 heartbeat("P", now - 600),
386 heartbeat("P", now - 60),
387 heartbeat("P", now - 300),
388 ],
389 receipts: vec![],
390 consumers: HashMap::new(),
391 stake_statuses: HashMap::new(),
392 anchor_providers: HashSet::new(),
393 };
394 let snap = compute_snapshot(&input, now);
395 assert_eq!(snap.providers[0].last_seen_unix, Some(now - 60));
396 }
397
398 #[test]
399 fn empty_input_yields_empty_provider_list() {
400 let now = 1_700_000_000;
401 let input = AggregatorInput {
402 offers: vec![],
403 heartbeats: vec![],
404 receipts: vec![],
405 consumers: HashMap::new(),
406 stake_statuses: HashMap::new(),
407 anchor_providers: HashSet::new(),
408 };
409 let snap = compute_snapshot(&input, now);
410 assert_eq!(snap.providers.len(), 0);
411 assert_eq!(snap.version, SNAPSHOT_VERSION);
412 assert_eq!(snap.receipt_window_secs, RECEIPT_WINDOW_SECS);
413 }
414}