Skip to main content

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::*;
10use std::collections::HashSet;
11use std::sync::LazyLock;
12
13// Guy Fawkes Day 2025 — V for Vector badge claim window.
14const FAWKES_DAY_START: u64 = 1762300800; // 2025-11-05 00:00:00 UTC
15const FAWKES_DAY_END: u64 = 1762387200; // 2025-11-06 00:00:00 UTC
16
17/// Per-account settings key: "true" once we've confirmed the Vector badge.
18const BADGE_VECTOR_KEY: &str = "badge_vector";
19/// Per-account settings key: unix-secs of the last unsuccessful resolve pass.
20/// Throttles re-checking for accounts that don't (yet) hold the badge.
21const BADGE_CHECK_TS_KEY: &str = "badge_check_ts";
22/// Don't re-run the full retry loop more than this often for an account we've
23/// already checked without success. The claim window is permanently closed, so
24/// a non-holder can never become a holder — frequent restarts shouldn't each
25/// trigger a fresh relay sweep.
26const 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
35/// Whether a kind-30078 event is a valid Fawkes badge claim: right content and
36/// a timestamp inside the (half-open) event window. Pure so it's unit-testable.
37fn 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
43/// Fetch + validate whether `pubkey` holds the V for Vector (Guy Fawkes 2025)
44/// badge: a kind-30078 `d=fawkes_2025` claim published within the event window.
45///
46/// Queries the full relay pool rather than only the trusted relays: the claim
47/// was published during the event to whatever relays the holder used, and any
48/// single relay (including a trusted one) can be transiently down. Broadest
49/// net gives the best chance of locating the permanent claim.
50pub 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_D, "fawkes_2025")
56        // > 1 to tolerate relays serving superseded copies of the replaceable
57        // event alongside the current one.
58        .limit(10);
59    let mut events = client
60        .stream_events(filter)
61        .timeout(std::time::Duration::from_secs(10))
62        .await
63        .map_err(|e| e.to_string())?;
64    // 0.45 streams (relay, Result<Event>) so callers can attribute per-relay failures.
65    while let Some((_relay, res)) = events.next().await {
66        let Ok(event) = res else { continue };
67        if is_valid_fawkes_claim(&event.content, event.created_at.as_secs()) {
68            return Ok(true);
69        }
70    }
71    Ok(false)
72}
73
74/// Cached flag for whether we hold the Vector badge. Cheap + synchronous, so
75/// safe to call from limit checks. Defaults to false when unset — badge perks
76/// stay off until the cache is filled post-sync.
77pub fn has_vector_badge() -> bool {
78    crate::db::get_sql_setting(BADGE_VECTOR_KEY.to_string())
79        .ok()
80        .flatten()
81        .map(|v| v == "true")
82        .unwrap_or(false)
83}
84
85/// Per-account settings key: cached Bug Hunter NIP-58 tier (0-3). Filled by the
86/// award fetch; downgraded only on a seen issuer revocation, never on absence.
87const BADGE_BUG_HUNTER_TIER_KEY: &str = "badge_bug_hunter_tier";
88
89/// Per-account settings key: comma-separated hex ids of the award events backing
90/// the cached tier, so a revocation still resolves after the relays purge the award.
91const BADGE_BUG_HUNTER_AWARD_IDS_KEY: &str = "badge_bug_hunter_award_ids";
92
93/// Cached Bug Hunter tier (0-3) for the current account. Cheap + synchronous;
94/// 0 until the award fetch fills it.
95pub fn bug_hunter_tier() -> u8 {
96    crate::db::get_sql_setting(BADGE_BUG_HUNTER_TIER_KEY.to_string())
97        .ok()
98        .flatten()
99        .and_then(|v| v.parse::<u8>().ok())
100        .map(|t| t.min(3))
101        .unwrap_or(0)
102}
103
104/// The account's effective premium tier (0-3): the higher of the Bug Hunter tier
105/// and the V for Vector badge (full premium = tier 3). Per-account perks (emoji
106/// limits) read this.
107pub fn effective_tier() -> u8 {
108    bug_hunter_tier().max(if has_vector_badge() { 3 } else { 0 })
109}
110
111/// Per-effective-tier cap on NEW reaction groups one user may open on a single
112/// message. Joining an existing reaction (+1) is never gated — only introducing
113/// an emoji the message doesn't yet carry spends this allowance.
114pub const NEW_REACTIONS_PER_POST_BY_TIER: [usize; 4] = [6, 6, 9, 12];
115
116/// The current account's fresh-reaction allowance per message.
117pub fn effective_max_new_reactions_per_post() -> usize {
118    NEW_REACTIONS_PER_POST_BY_TIER[effective_tier() as usize]
119}
120
121/// Sender-side gate for reacting to `reference_id` with `emoji`: joining an
122/// existing group always passes; opening a new group checks the message-wide
123/// group ceiling, then the per-tier allowance. "Groups I opened" is judged by
124/// insertion order in our own view — the earliest held reaction per emoji.
125/// `Ok(())` when the send may proceed.
126pub async fn check_new_reaction_allowance(reference_id: &str, emoji: &str) -> Result<(), String> {
127    use nostr_sdk::prelude::ToBech32;
128    let me = match crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
129        Some(npub) => npub,
130        None => return Err("Not logged in".to_string()),
131    };
132    let st = crate::state::STATE.lock().await;
133    let Some((_, message)) = st.find_message(reference_id) else { return Ok(()) };
134    if message.reactions.iter().any(|r| r.emoji == emoji) {
135        return Ok(());
136    }
137    let mut first_by_emoji: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
138    for r in &message.reactions {
139        first_by_emoji.entry(r.emoji.as_str()).or_insert(r.author_id.as_str());
140    }
141    if first_by_emoji.len() >= crate::compact::MAX_REACTION_GROUPS {
142        return Err("This message has reached its reaction limit".to_string());
143    }
144    let spent = first_by_emoji.values().filter(|a| **a == me).count();
145    if spent >= effective_max_new_reactions_per_post() {
146        return Err("You've used all your new reactions on this message".to_string());
147    }
148    Ok(())
149}
150
151/// The highest effective tier across ALL accounts on this install. The
152/// multi-account cap is device-level (adding a profile spans accounts), so it
153/// must not drop when you switch to an un-badged account — unlike the per-account
154/// perks. Reads each account's badge state straight from its vector.db.
155pub fn max_account_tier() -> u8 {
156    let mut max = effective_tier();
157    if let Ok(accounts) = crate::db::get_accounts() {
158        for npub in accounts {
159            max = max.max(read_account_tier(&npub).unwrap_or(0));
160        }
161    }
162    max
163}
164
165/// A (possibly non-active) account's effective tier, read read-only from its
166/// vector.db settings (plaintext KV). None if the DB/keys are absent or locked.
167fn read_account_tier(npub: &str) -> Option<u8> {
168    let path = crate::db::account_dir(npub).ok()?.join("vector.db");
169    let conn = rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
170    let get = |key: &str| -> Option<String> {
171        conn.query_row("SELECT value FROM settings WHERE key = ?1", rusqlite::params![key], |r| r.get(0)).ok()
172    };
173    let bug = get(BADGE_BUG_HUNTER_TIER_KEY).and_then(|v| v.parse::<u8>().ok()).map(|t| t.min(3)).unwrap_or(0);
174    let vector = get(BADGE_VECTOR_KEY).map(|v| v == "true").unwrap_or(false);
175    Some(bug.max(if vector { 3 } else { 0 }))
176}
177
178/// Record the result of an on-demand badge check. When the checked key is our
179/// own and the badge is present, persist it (sticky) and emit `badges_updated`
180/// so badge-gated perks (raised emoji-pack limits) turn on immediately — this is
181/// the safety net for a post-sync `refresh_own_badges` that missed the claim
182/// (the holding relay is often flaky during the saturated sync window) and is now
183/// sitting in its multi-hour re-check cooldown. An on-demand check runs at a
184/// quiet moment, so it lands where the sync-time sweep didn't.
185///
186/// No-op for other users, a negative result, or an account swap mid-check (the
187/// own-key comparison re-reads the *current* account, so a stale key never
188/// writes the wrong DB). No awaits, so the read + write stay on one account.
189pub fn note_own_badge_confirmed(pubkey: &PublicKey, has_badge: bool) {
190    if !has_badge || has_vector_badge() {
191        return;
192    }
193    if crate::state::my_public_key().as_ref() != Some(pubkey) {
194        return;
195    }
196    let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
197    crate::log_info!("[Badges] vector badge confirmed via on-demand check");
198    crate::traits::emit_event_json(
199        "badges_updated",
200        serde_json::json!({ "vector": true, "tier": effective_tier(), "bug_hunter": bug_hunter_tier() }),
201    );
202}
203
204/// Fetch our own badges and persist to the per-account cache. Called once
205/// after initial sync. The std::sync::Arc<crate::db::Session> straddles the network fetch so a
206/// mid-fetch account swap can't write account A's badge into account B's DB.
207pub async fn refresh_own_badges() {
208    crate::db::scoped(async move {
209        let Some(pk) = crate::state::my_public_key() else {
210            crate::log_warn!("[Badges] refresh skipped — no public key");
211            return;
212        };
213
214        // Sticky: the badge is a permanent achievement, so once confirmed we never
215        // re-query (avoids a flaky relay later flipping it off) and never downgrade.
216        if has_vector_badge() {
217            crate::log_info!("[Badges] vector badge already cached — skipping refresh");
218            return;
219        }
220
221        // Throttle: skip the relay sweep if we already checked recently without
222        // success. The window is closed, so a miss now will still be a miss in an
223        // hour — no need to re-sweep on every restart.
224        let now = unix_now();
225        if let Some(last) = crate::db::get_sql_setting(BADGE_CHECK_TS_KEY.to_string())
226            .ok()
227            .flatten()
228            .and_then(|v| v.parse::<u64>().ok())
229        {
230            if now.saturating_sub(last) < RECHECK_COOLDOWN_SECS {
231                return;
232            }
233        }
234
235        crate::log_info!(
236            "[Badges] resolving own badges for {}…",
237            pk.to_bech32().unwrap_or_default()
238        );
239
240        // The holding relay (often the user's own) is flaky/overloaded during the
241        // heavy sync window, so retry a few times to catch it during a quiet
242        // moment. A miss leaves the badge cache untouched (records only the check
243        // time for the cooldown); the next boot past the cooldown tries again until
244        // it lands once (then sticky-cached forever).
245        const ATTEMPTS: u8 = 3;
246        for attempt in 1..=ATTEMPTS {
247            match has_fawkes_badge(&pk).await {
248                Ok(true) => {
249                    crate::log_info!("[Badges] vector badge confirmed (attempt {})", attempt);
250                    let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
251                    return;
252                }
253                Ok(false) => {
254                    crate::log_info!("[Badges] vector badge not found (attempt {}/{})", attempt, ATTEMPTS);
255                }
256                Err(e) => {
257                    crate::log_warn!("[Badges] refresh attempt {}/{} failed: {}", attempt, ATTEMPTS, e);
258                }
259            }
260            if attempt < ATTEMPTS {
261                tokio::time::sleep(std::time::Duration::from_secs(20)).await;
262            }
263        }
264        // Record the unsuccessful pass so the cooldown applies before re-sweeping.
265        let _ = crate::db::set_sql_setting(BADGE_CHECK_TS_KEY.to_string(), now.to_string());
266        crate::log_info!("[Badges] vector badge not resolved this boot — will retry after cooldown");
267    })
268    .await
269}
270
271// ── Bug Hunter (NIP-58 tiered, team-awarded) ───────────────────────────────
272
273/// The Vector Team issuer key — the NIP-58 trust root for Bug Hunter badges.
274/// Only awards (kind 8) and revocations (kind 5) signed by this key are honored,
275/// so a forged award from any other key grants nothing.
276const BUG_HUNTER_ISSUER_NPUB: &str =
277    "npub1hrujuc08r4zcdtn0u6ts7u7apldcjqgftz0z7stmaaz9hwaf9jxs66f3yh";
278
279static BUG_HUNTER_ISSUER: LazyLock<PublicKey> = LazyLock::new(|| {
280    PublicKey::from_bech32(BUG_HUNTER_ISSUER_NPUB)
281        .expect("hardcoded Bug Hunter issuer npub must be valid")
282});
283
284/// Map a badge-definition `d` identifier to its tier. The display `name` can
285/// change freely; the `d` slug is the permanent identity.
286fn tier_from_slug(d: &str) -> Option<u8> {
287    match d {
288        "bug-hunter-tier-1" => Some(1),
289        "bug-hunter-tier-2" => Some(2),
290        "bug-hunter-tier-3" => Some(3),
291        _ => None,
292    }
293}
294
295/// Parse a NIP-58 `a`-tag coordinate (`30009:<issuer-hex>:<d>`) into a tier, but
296/// only when the kind is 30009 AND the author is our trusted issuer — an award
297/// pointing at a definition minted under any other key is ignored.
298fn tier_from_coord(coord: &str, issuer_hex: &str) -> Option<u8> {
299    let mut parts = coord.splitn(3, ':');
300    let kind = parts.next()?;
301    let author = parts.next()?;
302    let d = parts.next()?;
303    if kind != "30009" || author != issuer_hex {
304        return None;
305    }
306    tier_from_slug(d)
307}
308
309/// Fold awards (event id + tier) and the set of revoked award ids into the tier
310/// seen this fetch, plus whether a revocation actually applied to one of our
311/// awards. Pure so it's unit-testable. `saw_revocation` is the positive signal
312/// that justifies a downgrade (vs. a flaky-relay absence, which must not).
313fn fold_bug_hunter(awards: &[(EventId, u8)], revoked: &HashSet<EventId>) -> (u8, bool) {
314    let mut seen_tier = 0u8;
315    let mut saw_revocation = false;
316    for (id, tier) in awards {
317        if revoked.contains(id) {
318            saw_revocation = true;
319        } else {
320            seen_tier = seen_tier.max(*tier);
321        }
322    }
323    (seen_tier, saw_revocation)
324}
325
326/// Fetch `pubkey`'s raw Bug Hunter standing from the issuer: the seen kind-8
327/// awards (id + tier) whose `a` points at one of our tier definitions, plus the
328/// set of award ids the issuer has revoked (kind-5). Queries the full pool.
329async fn fetch_bug_hunter_raw(pubkey: &PublicKey) -> Result<(Vec<(EventId, u8)>, HashSet<EventId>), String> {
330    let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
331    let issuer = *BUG_HUNTER_ISSUER;
332    let issuer_hex = issuer.to_hex();
333
334    // Awards: kind 8 signed by the issuer, p-tagging this user.
335    let award_filter = Filter::new()
336        .author(issuer)
337        .kind(Kind::Custom(8))
338        .custom_tag(SingleLetterTag::LOWERCASE_P, pubkey.to_hex())
339        .limit(64);
340    let mut awards: Vec<(EventId, u8)> = Vec::new();
341    let mut stream = client
342        .stream_events(award_filter)
343        .timeout(std::time::Duration::from_secs(10))
344        .await
345        .map_err(|e| e.to_string())?;
346    while let Some((_relay, res)) = stream.next().await {
347        let Ok(ev) = res else { continue };
348        let coord = ev.tags.iter().find_map(|t| {
349            let s = t.as_slice();
350            if s.first().map(|k| k == "a").unwrap_or(false) {
351                s.get(1).cloned()
352            } else {
353                None
354            }
355        });
356        if let Some(c) = coord {
357            if let Some(tier) = tier_from_coord(&c, &issuer_hex) {
358                awards.push((ev.id, tier));
359            }
360        }
361    }
362
363    // Revocations: kind 5 from the issuer (NIP-09); each `e` tag names a revoked award.
364    let revoke_filter = Filter::new()
365        .author(issuer)
366        .kind(Kind::Custom(5))
367        .limit(256);
368    let mut revoked: HashSet<EventId> = HashSet::new();
369    let mut rstream = client
370        .stream_events(revoke_filter)
371        .timeout(std::time::Duration::from_secs(10))
372        .await
373        .map_err(|e| e.to_string())?;
374    while let Some((_relay, res)) = rstream.next().await {
375        let Ok(ev) = res else { continue };
376        for t in ev.tags.iter() {
377            let s = t.as_slice();
378            if s.first().map(|k| k == "e").unwrap_or(false) {
379                if let Some(id) = s.get(1).and_then(|h| EventId::from_hex(h).ok()) {
380                    revoked.insert(id);
381                }
382            }
383        }
384    }
385
386    Ok((awards, revoked))
387}
388
389/// Highest non-revoked tier + whether a seen award was revoked, for displaying
390/// another user's badge. Our own status goes through `refresh_own_bug_hunter`,
391/// which also catches revocation of a since-purged award.
392pub async fn fetch_bug_hunter_tier(pubkey: &PublicKey) -> Result<(u8, bool), String> {
393    let (awards, revoked) = fetch_bug_hunter_raw(pubkey).await?;
394    Ok(fold_bug_hunter(&awards, &revoked))
395}
396
397fn read_cached_award_ids() -> Vec<EventId> {
398    crate::db::get_sql_setting(BADGE_BUG_HUNTER_AWARD_IDS_KEY.to_string())
399        .ok()
400        .flatten()
401        .map(|s| s.split(',').filter_map(|h| EventId::from_hex(h.trim()).ok()).collect())
402        .unwrap_or_default()
403}
404
405fn write_cached_award_ids(ids: &[EventId]) {
406    let csv = ids.iter().map(|id| id.to_hex()).collect::<Vec<_>>().join(",");
407    let _ = crate::db::set_sql_setting(BADGE_BUG_HUNTER_AWARD_IDS_KEY.to_string(), csv);
408}
409
410/// Resolve + persist our own Bug Hunter tier (called post-sync). Sticky cache:
411/// upgrades apply immediately; a downgrade is honored ONLY on a seen revocation,
412/// never a mere absent award (flaky relay). The ids of the awards backing the
413/// current tier are cached so a revocation still resolves after the relays purge
414/// the award itself (NIP-09 deletion).
415pub async fn refresh_own_bug_hunter() {
416    let Some(pk) = crate::state::my_public_key() else {
417        return;
418    };
419
420    let (awards, revoked) = match fetch_bug_hunter_raw(&pk).await {
421        Ok(r) => r,
422        Err(e) => {
423            crate::log_warn!("[Badges] bug hunter fetch failed: {}", e);
424            return;
425        }
426    };
427
428    // Highest non-revoked seen tier + the award ids backing it.
429    let mut seen_tier = 0u8;
430    let mut active_ids: Vec<EventId> = Vec::new();
431    let mut seen_revoked = false;
432    for (id, tier) in &awards {
433        if revoked.contains(id) {
434            seen_revoked = true;
435        } else {
436            seen_tier = seen_tier.max(*tier);
437            active_ids.push(*id);
438        }
439    }
440    // Also honor a revocation of an award we previously cached but that the relays
441    // have since purged (so it's no longer in `awards` to match directly).
442    let cached_revoked = read_cached_award_ids().iter().any(|id| revoked.contains(id));
443    let saw_revocation = seen_revoked || cached_revoked;
444
445    // Re-read the current account before any write: never persist A's state into B.
446    if crate::state::my_public_key().as_ref() != Some(&pk) {
447        return;
448    }
449
450    let cached = bug_hunter_tier();
451    let new_tier = if seen_tier > cached {
452        seen_tier
453    } else if seen_tier < cached && saw_revocation {
454        seen_tier
455    } else {
456        cached
457    };
458
459    // Remember the awards behind the current tier — but only when we actually saw
460    // awards, so a flaky empty fetch never wipes the memory we need for revocation.
461    if !awards.is_empty() {
462        write_cached_award_ids(&active_ids);
463    }
464    if new_tier == cached {
465        return;
466    }
467    let _ = crate::db::set_sql_setting(
468        BADGE_BUG_HUNTER_TIER_KEY.to_string(),
469        new_tier.to_string(),
470    );
471    crate::log_info!("[Badges] bug hunter tier {} -> {}", cached, new_tier);
472    crate::traits::emit_event_json(
473        "badges_updated",
474        serde_json::json!({ "vector": has_vector_badge(), "tier": effective_tier(), "bug_hunter": bug_hunter_tier() }),
475    );
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn fawkes_claim_window_boundaries() {
484        // Correct content, inside the window.
485        assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START));
486        assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END - 1));
487        // End is exclusive.
488        assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END));
489        // Before the window.
490        assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START - 1));
491        // Wrong / empty content, even inside the window.
492        assert!(!is_valid_fawkes_claim("", FAWKES_DAY_START));
493        assert!(!is_valid_fawkes_claim("something_else", FAWKES_DAY_START));
494    }
495
496    #[test]
497    fn bug_hunter_issuer_npub_is_valid() {
498        // A malformed hardcoded issuer would panic when the LazyLock forces.
499        let _ = *BUG_HUNTER_ISSUER;
500        assert!(PublicKey::from_bech32(BUG_HUNTER_ISSUER_NPUB).is_ok());
501    }
502
503    #[test]
504    fn tier_from_coord_trusts_only_issuer_kind_and_slug() {
505        let issuer = "abc123";
506        assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-1", issuer), Some(1));
507        assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-2", issuer), Some(2));
508        assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-3", issuer), Some(3));
509        // Forged: a definition minted under another key.
510        assert_eq!(tier_from_coord("30009:evil:bug-hunter-tier-3", issuer), None);
511        // Wrong kind.
512        assert_eq!(tier_from_coord("30008:abc123:bug-hunter-tier-3", issuer), None);
513        // Unknown slug.
514        assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-9", issuer), None);
515    }
516
517    #[test]
518    fn fold_bug_hunter_highest_non_revoked_with_revocation_flag() {
519        let id = |b: u8| EventId::from_hex(&format!("{:02x}", b).repeat(32)).unwrap();
520        // No revocations: highest tier, flag clear.
521        assert_eq!(fold_bug_hunter(&[(id(1), 1), (id(3), 3), (id(2), 2)], &HashSet::new()), (3, false));
522        // Revoke the tier-3 award: tier drops to 2, flag set.
523        let revoked: HashSet<EventId> = [id(3)].into_iter().collect();
524        assert_eq!(fold_bug_hunter(&[(id(1), 1), (id(3), 3), (id(2), 2)], &revoked), (2, true));
525        // No awards: tier 0, no revocation.
526        assert_eq!(fold_bug_hunter(&[], &HashSet::new()), (0, false));
527    }
528}