1use nostr_sdk::prelude::*;
10use std::collections::HashSet;
11use std::sync::LazyLock;
12
13const FAWKES_DAY_START: u64 = 1762300800; const FAWKES_DAY_END: u64 = 1762387200; const BADGE_VECTOR_KEY: &str = "badge_vector";
19const BADGE_CHECK_TS_KEY: &str = "badge_check_ts";
22const RECHECK_COOLDOWN_SECS: u64 = 6 * 3600;
27
28fn unix_now() -> u64 {
29 std::time::SystemTime::now()
30 .duration_since(std::time::UNIX_EPOCH)
31 .map(|d| d.as_secs())
32 .unwrap_or(0)
33}
34
35fn is_valid_fawkes_claim(content: &str, created_at: u64) -> bool {
38 content == "fawkes_badge_claimed"
39 && created_at >= FAWKES_DAY_START
40 && created_at < FAWKES_DAY_END
41}
42
43pub async fn has_fawkes_badge(pubkey: &PublicKey) -> Result<bool, String> {
51 let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
52 let filter = Filter::new()
53 .author(*pubkey)
54 .kind(Kind::ApplicationSpecificData)
55 .custom_tag(SingleLetterTag::lowercase(Alphabet::D), "fawkes_2025")
56 .limit(10);
59 let mut events = client
60 .stream_events(filter, std::time::Duration::from_secs(10))
61 .await
62 .map_err(|e| e.to_string())?;
63 while let Some(event) = events.next().await {
64 if is_valid_fawkes_claim(&event.content, event.created_at.as_secs()) {
65 return Ok(true);
66 }
67 }
68 Ok(false)
69}
70
71pub fn has_vector_badge() -> bool {
75 crate::db::get_sql_setting(BADGE_VECTOR_KEY.to_string())
76 .ok()
77 .flatten()
78 .map(|v| v == "true")
79 .unwrap_or(false)
80}
81
82const BADGE_BUG_HUNTER_TIER_KEY: &str = "badge_bug_hunter_tier";
85
86const BADGE_BUG_HUNTER_AWARD_IDS_KEY: &str = "badge_bug_hunter_award_ids";
89
90pub fn bug_hunter_tier() -> u8 {
93 crate::db::get_sql_setting(BADGE_BUG_HUNTER_TIER_KEY.to_string())
94 .ok()
95 .flatten()
96 .and_then(|v| v.parse::<u8>().ok())
97 .map(|t| t.min(3))
98 .unwrap_or(0)
99}
100
101pub fn effective_tier() -> u8 {
105 bug_hunter_tier().max(if has_vector_badge() { 3 } else { 0 })
106}
107
108pub fn max_account_tier() -> u8 {
113 let mut max = effective_tier();
114 if let Ok(accounts) = crate::db::get_accounts() {
115 for npub in accounts {
116 max = max.max(read_account_tier(&npub).unwrap_or(0));
117 }
118 }
119 max
120}
121
122fn read_account_tier(npub: &str) -> Option<u8> {
125 let path = crate::db::account_dir(npub).ok()?.join("vector.db");
126 let conn = rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
127 let get = |key: &str| -> Option<String> {
128 conn.query_row("SELECT value FROM settings WHERE key = ?1", rusqlite::params![key], |r| r.get(0)).ok()
129 };
130 let bug = get(BADGE_BUG_HUNTER_TIER_KEY).and_then(|v| v.parse::<u8>().ok()).map(|t| t.min(3)).unwrap_or(0);
131 let vector = get(BADGE_VECTOR_KEY).map(|v| v == "true").unwrap_or(false);
132 Some(bug.max(if vector { 3 } else { 0 }))
133}
134
135pub fn note_own_badge_confirmed(pubkey: &PublicKey, has_badge: bool) {
147 if !has_badge || has_vector_badge() {
148 return;
149 }
150 if crate::state::my_public_key().as_ref() != Some(pubkey) {
151 return;
152 }
153 let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
154 crate::log_info!("[Badges] vector badge confirmed via on-demand check");
155 crate::traits::emit_event_json(
156 "badges_updated",
157 serde_json::json!({ "vector": true, "tier": effective_tier(), "bug_hunter": bug_hunter_tier() }),
158 );
159}
160
161pub async fn refresh_own_badges() {
165 let session = crate::state::SessionGuard::capture();
166 let Some(pk) = crate::state::my_public_key() else {
167 crate::log_warn!("[Badges] refresh skipped — no public key");
168 return;
169 };
170
171 if has_vector_badge() {
174 crate::log_info!("[Badges] vector badge already cached — skipping refresh");
175 return;
176 }
177
178 let now = unix_now();
182 if let Some(last) = crate::db::get_sql_setting(BADGE_CHECK_TS_KEY.to_string())
183 .ok()
184 .flatten()
185 .and_then(|v| v.parse::<u64>().ok())
186 {
187 if now.saturating_sub(last) < RECHECK_COOLDOWN_SECS {
188 return;
189 }
190 }
191
192 crate::log_info!(
193 "[Badges] resolving own badges for {}…",
194 pk.to_bech32().unwrap_or_default()
195 );
196
197 const ATTEMPTS: u8 = 3;
203 for attempt in 1..=ATTEMPTS {
204 match has_fawkes_badge(&pk).await {
205 Ok(true) => {
206 if !session.is_valid() {
207 return;
208 }
209 crate::log_info!("[Badges] vector badge confirmed (attempt {})", attempt);
210 let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
211 return;
212 }
213 Ok(false) => {
214 crate::log_info!("[Badges] vector badge not found (attempt {}/{})", attempt, ATTEMPTS);
215 }
216 Err(e) => {
217 crate::log_warn!("[Badges] refresh attempt {}/{} failed: {}", attempt, ATTEMPTS, e);
218 }
219 }
220 if !session.is_valid() {
221 return;
222 }
223 if attempt < ATTEMPTS {
224 tokio::time::sleep(std::time::Duration::from_secs(20)).await;
225 }
226 }
227 if session.is_valid() {
229 let _ = crate::db::set_sql_setting(BADGE_CHECK_TS_KEY.to_string(), now.to_string());
230 }
231 crate::log_info!("[Badges] vector badge not resolved this boot — will retry after cooldown");
232}
233
234const BUG_HUNTER_ISSUER_NPUB: &str =
240 "npub1hrujuc08r4zcdtn0u6ts7u7apldcjqgftz0z7stmaaz9hwaf9jxs66f3yh";
241
242static BUG_HUNTER_ISSUER: LazyLock<PublicKey> = LazyLock::new(|| {
243 PublicKey::from_bech32(BUG_HUNTER_ISSUER_NPUB)
244 .expect("hardcoded Bug Hunter issuer npub must be valid")
245});
246
247fn tier_from_slug(d: &str) -> Option<u8> {
250 match d {
251 "bug-hunter-tier-1" => Some(1),
252 "bug-hunter-tier-2" => Some(2),
253 "bug-hunter-tier-3" => Some(3),
254 _ => None,
255 }
256}
257
258fn tier_from_coord(coord: &str, issuer_hex: &str) -> Option<u8> {
262 let mut parts = coord.splitn(3, ':');
263 let kind = parts.next()?;
264 let author = parts.next()?;
265 let d = parts.next()?;
266 if kind != "30009" || author != issuer_hex {
267 return None;
268 }
269 tier_from_slug(d)
270}
271
272fn fold_bug_hunter(awards: &[(EventId, u8)], revoked: &HashSet<EventId>) -> (u8, bool) {
277 let mut seen_tier = 0u8;
278 let mut saw_revocation = false;
279 for (id, tier) in awards {
280 if revoked.contains(id) {
281 saw_revocation = true;
282 } else {
283 seen_tier = seen_tier.max(*tier);
284 }
285 }
286 (seen_tier, saw_revocation)
287}
288
289async fn fetch_bug_hunter_raw(pubkey: &PublicKey) -> Result<(Vec<(EventId, u8)>, HashSet<EventId>), String> {
293 let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
294 let issuer = *BUG_HUNTER_ISSUER;
295 let issuer_hex = issuer.to_hex();
296
297 let award_filter = Filter::new()
299 .author(issuer)
300 .kind(Kind::Custom(8))
301 .custom_tag(SingleLetterTag::lowercase(Alphabet::P), pubkey.to_hex())
302 .limit(64);
303 let mut awards: Vec<(EventId, u8)> = Vec::new();
304 let mut stream = client
305 .stream_events(award_filter, std::time::Duration::from_secs(10))
306 .await
307 .map_err(|e| e.to_string())?;
308 while let Some(ev) = stream.next().await {
309 let coord = ev.tags.iter().find_map(|t| {
310 let s = t.as_slice();
311 if s.first().map(|k| k == "a").unwrap_or(false) {
312 s.get(1).cloned()
313 } else {
314 None
315 }
316 });
317 if let Some(c) = coord {
318 if let Some(tier) = tier_from_coord(&c, &issuer_hex) {
319 awards.push((ev.id, tier));
320 }
321 }
322 }
323
324 let revoke_filter = Filter::new()
326 .author(issuer)
327 .kind(Kind::Custom(5))
328 .limit(256);
329 let mut revoked: HashSet<EventId> = HashSet::new();
330 let mut rstream = client
331 .stream_events(revoke_filter, std::time::Duration::from_secs(10))
332 .await
333 .map_err(|e| e.to_string())?;
334 while let Some(ev) = rstream.next().await {
335 for t in ev.tags.iter() {
336 let s = t.as_slice();
337 if s.first().map(|k| k == "e").unwrap_or(false) {
338 if let Some(id) = s.get(1).and_then(|h| EventId::from_hex(h).ok()) {
339 revoked.insert(id);
340 }
341 }
342 }
343 }
344
345 Ok((awards, revoked))
346}
347
348pub async fn fetch_bug_hunter_tier(pubkey: &PublicKey) -> Result<(u8, bool), String> {
352 let (awards, revoked) = fetch_bug_hunter_raw(pubkey).await?;
353 Ok(fold_bug_hunter(&awards, &revoked))
354}
355
356fn read_cached_award_ids() -> Vec<EventId> {
357 crate::db::get_sql_setting(BADGE_BUG_HUNTER_AWARD_IDS_KEY.to_string())
358 .ok()
359 .flatten()
360 .map(|s| s.split(',').filter_map(|h| EventId::from_hex(h.trim()).ok()).collect())
361 .unwrap_or_default()
362}
363
364fn write_cached_award_ids(ids: &[EventId]) {
365 let csv = ids.iter().map(|id| id.to_hex()).collect::<Vec<_>>().join(",");
366 let _ = crate::db::set_sql_setting(BADGE_BUG_HUNTER_AWARD_IDS_KEY.to_string(), csv);
367}
368
369pub async fn refresh_own_bug_hunter() {
376 let session = crate::state::SessionGuard::capture();
377 let Some(pk) = crate::state::my_public_key() else {
378 return;
379 };
380
381 let (awards, revoked) = match fetch_bug_hunter_raw(&pk).await {
382 Ok(r) => r,
383 Err(e) => {
384 crate::log_warn!("[Badges] bug hunter fetch failed: {}", e);
385 return;
386 }
387 };
388 if !session.is_valid() {
389 return;
390 }
391
392 let mut seen_tier = 0u8;
394 let mut active_ids: Vec<EventId> = Vec::new();
395 let mut seen_revoked = false;
396 for (id, tier) in &awards {
397 if revoked.contains(id) {
398 seen_revoked = true;
399 } else {
400 seen_tier = seen_tier.max(*tier);
401 active_ids.push(*id);
402 }
403 }
404 let cached_revoked = read_cached_award_ids().iter().any(|id| revoked.contains(id));
407 let saw_revocation = seen_revoked || cached_revoked;
408
409 if crate::state::my_public_key().as_ref() != Some(&pk) {
411 return;
412 }
413
414 let cached = bug_hunter_tier();
415 let new_tier = if seen_tier > cached {
416 seen_tier
417 } else if seen_tier < cached && saw_revocation {
418 seen_tier
419 } else {
420 cached
421 };
422
423 if !awards.is_empty() {
426 write_cached_award_ids(&active_ids);
427 }
428 if new_tier == cached {
429 return;
430 }
431 let _ = crate::db::set_sql_setting(
432 BADGE_BUG_HUNTER_TIER_KEY.to_string(),
433 new_tier.to_string(),
434 );
435 crate::log_info!("[Badges] bug hunter tier {} -> {}", cached, new_tier);
436 crate::traits::emit_event_json(
437 "badges_updated",
438 serde_json::json!({ "vector": has_vector_badge(), "tier": effective_tier(), "bug_hunter": bug_hunter_tier() }),
439 );
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn fawkes_claim_window_boundaries() {
448 assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START));
450 assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END - 1));
451 assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END));
453 assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START - 1));
455 assert!(!is_valid_fawkes_claim("", FAWKES_DAY_START));
457 assert!(!is_valid_fawkes_claim("something_else", FAWKES_DAY_START));
458 }
459
460 #[test]
461 fn bug_hunter_issuer_npub_is_valid() {
462 let _ = *BUG_HUNTER_ISSUER;
464 assert!(PublicKey::from_bech32(BUG_HUNTER_ISSUER_NPUB).is_ok());
465 }
466
467 #[test]
468 fn tier_from_coord_trusts_only_issuer_kind_and_slug() {
469 let issuer = "abc123";
470 assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-1", issuer), Some(1));
471 assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-2", issuer), Some(2));
472 assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-3", issuer), Some(3));
473 assert_eq!(tier_from_coord("30009:evil:bug-hunter-tier-3", issuer), None);
475 assert_eq!(tier_from_coord("30008:abc123:bug-hunter-tier-3", issuer), None);
477 assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-9", issuer), None);
479 }
480
481 #[test]
482 fn fold_bug_hunter_highest_non_revoked_with_revocation_flag() {
483 let id = |b: u8| EventId::from_hex(&format!("{:02x}", b).repeat(32)).unwrap();
484 assert_eq!(fold_bug_hunter(&[(id(1), 1), (id(3), 3), (id(2), 2)], &HashSet::new()), (3, false));
486 let revoked: HashSet<EventId> = [id(3)].into_iter().collect();
488 assert_eq!(fold_bug_hunter(&[(id(1), 1), (id(3), 3), (id(2), 2)], &revoked), (2, true));
489 assert_eq!(fold_bug_hunter(&[], &HashSet::new()), (0, false));
491 }
492}