vector_core/badges.rs
1//! Profile badges — fetch, validate, and cache.
2//!
3//! Badges are not loaded during the critical boot path. The cache is filled
4//! once after initial sync (see `refresh_own_badges`) so badge-gated perks
5//! (e.g. raised emoji-pack limits) resolve without an on-demand network
6//! round-trip. `has_vector_badge` is the cheap synchronous reader used by
7//! those gates.
8
9use nostr_sdk::prelude::*;
10
11// Guy Fawkes Day 2025 — V for Vector badge claim window.
12const FAWKES_DAY_START: u64 = 1762300800; // 2025-11-05 00:00:00 UTC
13const FAWKES_DAY_END: u64 = 1762387200; // 2025-11-06 00:00:00 UTC
14
15/// Per-account settings key: "true" once we've confirmed the Vector badge.
16const BADGE_VECTOR_KEY: &str = "badge_vector";
17/// Per-account settings key: unix-secs of the last unsuccessful resolve pass.
18/// Throttles re-checking for accounts that don't (yet) hold the badge.
19const BADGE_CHECK_TS_KEY: &str = "badge_check_ts";
20/// Don't re-run the full retry loop more than this often for an account we've
21/// already checked without success. The claim window is permanently closed, so
22/// a non-holder can never become a holder — frequent restarts shouldn't each
23/// trigger a fresh relay sweep.
24const RECHECK_COOLDOWN_SECS: u64 = 6 * 3600;
25
26fn unix_now() -> u64 {
27 std::time::SystemTime::now()
28 .duration_since(std::time::UNIX_EPOCH)
29 .map(|d| d.as_secs())
30 .unwrap_or(0)
31}
32
33/// Whether a kind-30078 event is a valid Fawkes badge claim: right content and
34/// a timestamp inside the (half-open) event window. Pure so it's unit-testable.
35fn is_valid_fawkes_claim(content: &str, created_at: u64) -> bool {
36 content == "fawkes_badge_claimed"
37 && created_at >= FAWKES_DAY_START
38 && created_at < FAWKES_DAY_END
39}
40
41/// Fetch + validate whether `pubkey` holds the V for Vector (Guy Fawkes 2025)
42/// badge: a kind-30078 `d=fawkes_2025` claim published within the event window.
43///
44/// Queries the full relay pool rather than only the trusted relays: the claim
45/// was published during the event to whatever relays the holder used, and any
46/// single relay (including a trusted one) can be transiently down. Broadest
47/// net gives the best chance of locating the permanent claim.
48pub async fn has_fawkes_badge(pubkey: &PublicKey) -> Result<bool, String> {
49 let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
50 let filter = Filter::new()
51 .author(*pubkey)
52 .kind(Kind::ApplicationSpecificData)
53 .custom_tag(SingleLetterTag::lowercase(Alphabet::D), "fawkes_2025")
54 // > 1 to tolerate relays serving superseded copies of the replaceable
55 // event alongside the current one.
56 .limit(10);
57 let mut events = client
58 .stream_events(filter, std::time::Duration::from_secs(10))
59 .await
60 .map_err(|e| e.to_string())?;
61 while let Some(event) = events.next().await {
62 if is_valid_fawkes_claim(&event.content, event.created_at.as_secs()) {
63 return Ok(true);
64 }
65 }
66 Ok(false)
67}
68
69/// Cached flag for whether we hold the Vector badge. Cheap + synchronous, so
70/// safe to call from limit checks. Defaults to false when unset — badge perks
71/// stay off until the cache is filled post-sync.
72pub fn has_vector_badge() -> bool {
73 crate::db::get_sql_setting(BADGE_VECTOR_KEY.to_string())
74 .ok()
75 .flatten()
76 .map(|v| v == "true")
77 .unwrap_or(false)
78}
79
80/// Record the result of an on-demand badge check. When the checked key is our
81/// own and the badge is present, persist it (sticky) and emit `badges_updated`
82/// so badge-gated perks (raised emoji-pack limits) turn on immediately — this is
83/// the safety net for a post-sync `refresh_own_badges` that missed the claim
84/// (the holding relay is often flaky during the saturated sync window) and is now
85/// sitting in its multi-hour re-check cooldown. An on-demand check runs at a
86/// quiet moment, so it lands where the sync-time sweep didn't.
87///
88/// No-op for other users, a negative result, or an account swap mid-check (the
89/// own-key comparison re-reads the *current* account, so a stale key never
90/// writes the wrong DB). No awaits, so the read + write stay on one account.
91pub fn note_own_badge_confirmed(pubkey: &PublicKey, has_badge: bool) {
92 if !has_badge || has_vector_badge() {
93 return;
94 }
95 if crate::state::my_public_key().as_ref() != Some(pubkey) {
96 return;
97 }
98 let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
99 crate::log_info!("[Badges] vector badge confirmed via on-demand check");
100 crate::traits::emit_event_json("badges_updated", serde_json::json!({ "vector": true }));
101}
102
103/// Fetch our own badges and persist to the per-account cache. Called once
104/// after initial sync. The SessionGuard straddles the network fetch so a
105/// mid-fetch account swap can't write account A's badge into account B's DB.
106pub async fn refresh_own_badges() {
107 let session = crate::state::SessionGuard::capture();
108 let Some(pk) = crate::state::my_public_key() else {
109 crate::log_warn!("[Badges] refresh skipped — no public key");
110 return;
111 };
112
113 // Sticky: the badge is a permanent achievement, so once confirmed we never
114 // re-query (avoids a flaky relay later flipping it off) and never downgrade.
115 if has_vector_badge() {
116 crate::log_info!("[Badges] vector badge already cached — skipping refresh");
117 return;
118 }
119
120 // Throttle: skip the relay sweep if we already checked recently without
121 // success. The window is closed, so a miss now will still be a miss in an
122 // hour — no need to re-sweep on every restart.
123 let now = unix_now();
124 if let Some(last) = crate::db::get_sql_setting(BADGE_CHECK_TS_KEY.to_string())
125 .ok()
126 .flatten()
127 .and_then(|v| v.parse::<u64>().ok())
128 {
129 if now.saturating_sub(last) < RECHECK_COOLDOWN_SECS {
130 return;
131 }
132 }
133
134 crate::log_info!(
135 "[Badges] resolving own badges for {}…",
136 pk.to_bech32().unwrap_or_default()
137 );
138
139 // The holding relay (often the user's own) is flaky/overloaded during the
140 // heavy sync window, so retry a few times to catch it during a quiet
141 // moment. A miss leaves the badge cache untouched (records only the check
142 // time for the cooldown); the next boot past the cooldown tries again until
143 // it lands once (then sticky-cached forever).
144 const ATTEMPTS: u8 = 3;
145 for attempt in 1..=ATTEMPTS {
146 match has_fawkes_badge(&pk).await {
147 Ok(true) => {
148 if !session.is_valid() {
149 return;
150 }
151 crate::log_info!("[Badges] vector badge confirmed (attempt {})", attempt);
152 let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
153 return;
154 }
155 Ok(false) => {
156 crate::log_info!("[Badges] vector badge not found (attempt {}/{})", attempt, ATTEMPTS);
157 }
158 Err(e) => {
159 crate::log_warn!("[Badges] refresh attempt {}/{} failed: {}", attempt, ATTEMPTS, e);
160 }
161 }
162 if !session.is_valid() {
163 return;
164 }
165 if attempt < ATTEMPTS {
166 tokio::time::sleep(std::time::Duration::from_secs(20)).await;
167 }
168 }
169 // Record the unsuccessful pass so the cooldown applies before re-sweeping.
170 if session.is_valid() {
171 let _ = crate::db::set_sql_setting(BADGE_CHECK_TS_KEY.to_string(), now.to_string());
172 }
173 crate::log_info!("[Badges] vector badge not resolved this boot — will retry after cooldown");
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn fawkes_claim_window_boundaries() {
182 // Correct content, inside the window.
183 assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START));
184 assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END - 1));
185 // End is exclusive.
186 assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END));
187 // Before the window.
188 assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START - 1));
189 // Wrong / empty content, even inside the window.
190 assert!(!is_valid_fawkes_claim("", FAWKES_DAY_START));
191 assert!(!is_valid_fawkes_claim("something_else", FAWKES_DAY_START));
192 }
193}