use nostr_sdk::prelude::*;
use std::collections::HashSet;
use std::sync::LazyLock;
const FAWKES_DAY_START: u64 = 1762300800; const FAWKES_DAY_END: u64 = 1762387200;
const BADGE_VECTOR_KEY: &str = "badge_vector";
const BADGE_CHECK_TS_KEY: &str = "badge_check_ts";
const RECHECK_COOLDOWN_SECS: u64 = 6 * 3600;
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn is_valid_fawkes_claim(content: &str, created_at: u64) -> bool {
content == "fawkes_badge_claimed"
&& created_at >= FAWKES_DAY_START
&& created_at < FAWKES_DAY_END
}
pub async fn has_fawkes_badge(pubkey: &PublicKey) -> Result<bool, String> {
let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
let filter = Filter::new()
.author(*pubkey)
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), "fawkes_2025")
.limit(10);
let mut events = client
.stream_events(filter, std::time::Duration::from_secs(10))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = events.next().await {
if is_valid_fawkes_claim(&event.content, event.created_at.as_secs()) {
return Ok(true);
}
}
Ok(false)
}
pub fn has_vector_badge() -> bool {
crate::db::get_sql_setting(BADGE_VECTOR_KEY.to_string())
.ok()
.flatten()
.map(|v| v == "true")
.unwrap_or(false)
}
const BADGE_BUG_HUNTER_TIER_KEY: &str = "badge_bug_hunter_tier";
const BADGE_BUG_HUNTER_AWARD_IDS_KEY: &str = "badge_bug_hunter_award_ids";
pub fn bug_hunter_tier() -> u8 {
crate::db::get_sql_setting(BADGE_BUG_HUNTER_TIER_KEY.to_string())
.ok()
.flatten()
.and_then(|v| v.parse::<u8>().ok())
.map(|t| t.min(3))
.unwrap_or(0)
}
pub fn effective_tier() -> u8 {
bug_hunter_tier().max(if has_vector_badge() { 3 } else { 0 })
}
pub fn max_account_tier() -> u8 {
let mut max = effective_tier();
if let Ok(accounts) = crate::db::get_accounts() {
for npub in accounts {
max = max.max(read_account_tier(&npub).unwrap_or(0));
}
}
max
}
fn read_account_tier(npub: &str) -> Option<u8> {
let path = crate::db::account_dir(npub).ok()?.join("vector.db");
let conn = rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
let get = |key: &str| -> Option<String> {
conn.query_row("SELECT value FROM settings WHERE key = ?1", rusqlite::params![key], |r| r.get(0)).ok()
};
let bug = get(BADGE_BUG_HUNTER_TIER_KEY).and_then(|v| v.parse::<u8>().ok()).map(|t| t.min(3)).unwrap_or(0);
let vector = get(BADGE_VECTOR_KEY).map(|v| v == "true").unwrap_or(false);
Some(bug.max(if vector { 3 } else { 0 }))
}
pub fn note_own_badge_confirmed(pubkey: &PublicKey, has_badge: bool) {
if !has_badge || has_vector_badge() {
return;
}
if crate::state::my_public_key().as_ref() != Some(pubkey) {
return;
}
let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
crate::log_info!("[Badges] vector badge confirmed via on-demand check");
crate::traits::emit_event_json(
"badges_updated",
serde_json::json!({ "vector": true, "tier": effective_tier(), "bug_hunter": bug_hunter_tier() }),
);
}
pub async fn refresh_own_badges() {
let session = crate::state::SessionGuard::capture();
let Some(pk) = crate::state::my_public_key() else {
crate::log_warn!("[Badges] refresh skipped — no public key");
return;
};
if has_vector_badge() {
crate::log_info!("[Badges] vector badge already cached — skipping refresh");
return;
}
let now = unix_now();
if let Some(last) = crate::db::get_sql_setting(BADGE_CHECK_TS_KEY.to_string())
.ok()
.flatten()
.and_then(|v| v.parse::<u64>().ok())
{
if now.saturating_sub(last) < RECHECK_COOLDOWN_SECS {
return;
}
}
crate::log_info!(
"[Badges] resolving own badges for {}…",
pk.to_bech32().unwrap_or_default()
);
const ATTEMPTS: u8 = 3;
for attempt in 1..=ATTEMPTS {
match has_fawkes_badge(&pk).await {
Ok(true) => {
if !session.is_valid() {
return;
}
crate::log_info!("[Badges] vector badge confirmed (attempt {})", attempt);
let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
return;
}
Ok(false) => {
crate::log_info!("[Badges] vector badge not found (attempt {}/{})", attempt, ATTEMPTS);
}
Err(e) => {
crate::log_warn!("[Badges] refresh attempt {}/{} failed: {}", attempt, ATTEMPTS, e);
}
}
if !session.is_valid() {
return;
}
if attempt < ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_secs(20)).await;
}
}
if session.is_valid() {
let _ = crate::db::set_sql_setting(BADGE_CHECK_TS_KEY.to_string(), now.to_string());
}
crate::log_info!("[Badges] vector badge not resolved this boot — will retry after cooldown");
}
const BUG_HUNTER_ISSUER_NPUB: &str =
"npub1hrujuc08r4zcdtn0u6ts7u7apldcjqgftz0z7stmaaz9hwaf9jxs66f3yh";
static BUG_HUNTER_ISSUER: LazyLock<PublicKey> = LazyLock::new(|| {
PublicKey::from_bech32(BUG_HUNTER_ISSUER_NPUB)
.expect("hardcoded Bug Hunter issuer npub must be valid")
});
fn tier_from_slug(d: &str) -> Option<u8> {
match d {
"bug-hunter-tier-1" => Some(1),
"bug-hunter-tier-2" => Some(2),
"bug-hunter-tier-3" => Some(3),
_ => None,
}
}
fn tier_from_coord(coord: &str, issuer_hex: &str) -> Option<u8> {
let mut parts = coord.splitn(3, ':');
let kind = parts.next()?;
let author = parts.next()?;
let d = parts.next()?;
if kind != "30009" || author != issuer_hex {
return None;
}
tier_from_slug(d)
}
fn fold_bug_hunter(awards: &[(EventId, u8)], revoked: &HashSet<EventId>) -> (u8, bool) {
let mut seen_tier = 0u8;
let mut saw_revocation = false;
for (id, tier) in awards {
if revoked.contains(id) {
saw_revocation = true;
} else {
seen_tier = seen_tier.max(*tier);
}
}
(seen_tier, saw_revocation)
}
async fn fetch_bug_hunter_raw(pubkey: &PublicKey) -> Result<(Vec<(EventId, u8)>, HashSet<EventId>), String> {
let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
let issuer = *BUG_HUNTER_ISSUER;
let issuer_hex = issuer.to_hex();
let award_filter = Filter::new()
.author(issuer)
.kind(Kind::Custom(8))
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), pubkey.to_hex())
.limit(64);
let mut awards: Vec<(EventId, u8)> = Vec::new();
let mut stream = client
.stream_events(award_filter, std::time::Duration::from_secs(10))
.await
.map_err(|e| e.to_string())?;
while let Some(ev) = stream.next().await {
let coord = ev.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.first().map(|k| k == "a").unwrap_or(false) {
s.get(1).cloned()
} else {
None
}
});
if let Some(c) = coord {
if let Some(tier) = tier_from_coord(&c, &issuer_hex) {
awards.push((ev.id, tier));
}
}
}
let revoke_filter = Filter::new()
.author(issuer)
.kind(Kind::Custom(5))
.limit(256);
let mut revoked: HashSet<EventId> = HashSet::new();
let mut rstream = client
.stream_events(revoke_filter, std::time::Duration::from_secs(10))
.await
.map_err(|e| e.to_string())?;
while let Some(ev) = rstream.next().await {
for t in ev.tags.iter() {
let s = t.as_slice();
if s.first().map(|k| k == "e").unwrap_or(false) {
if let Some(id) = s.get(1).and_then(|h| EventId::from_hex(h).ok()) {
revoked.insert(id);
}
}
}
}
Ok((awards, revoked))
}
pub async fn fetch_bug_hunter_tier(pubkey: &PublicKey) -> Result<(u8, bool), String> {
let (awards, revoked) = fetch_bug_hunter_raw(pubkey).await?;
Ok(fold_bug_hunter(&awards, &revoked))
}
fn read_cached_award_ids() -> Vec<EventId> {
crate::db::get_sql_setting(BADGE_BUG_HUNTER_AWARD_IDS_KEY.to_string())
.ok()
.flatten()
.map(|s| s.split(',').filter_map(|h| EventId::from_hex(h.trim()).ok()).collect())
.unwrap_or_default()
}
fn write_cached_award_ids(ids: &[EventId]) {
let csv = ids.iter().map(|id| id.to_hex()).collect::<Vec<_>>().join(",");
let _ = crate::db::set_sql_setting(BADGE_BUG_HUNTER_AWARD_IDS_KEY.to_string(), csv);
}
pub async fn refresh_own_bug_hunter() {
let session = crate::state::SessionGuard::capture();
let Some(pk) = crate::state::my_public_key() else {
return;
};
let (awards, revoked) = match fetch_bug_hunter_raw(&pk).await {
Ok(r) => r,
Err(e) => {
crate::log_warn!("[Badges] bug hunter fetch failed: {}", e);
return;
}
};
if !session.is_valid() {
return;
}
let mut seen_tier = 0u8;
let mut active_ids: Vec<EventId> = Vec::new();
let mut seen_revoked = false;
for (id, tier) in &awards {
if revoked.contains(id) {
seen_revoked = true;
} else {
seen_tier = seen_tier.max(*tier);
active_ids.push(*id);
}
}
let cached_revoked = read_cached_award_ids().iter().any(|id| revoked.contains(id));
let saw_revocation = seen_revoked || cached_revoked;
if crate::state::my_public_key().as_ref() != Some(&pk) {
return;
}
let cached = bug_hunter_tier();
let new_tier = if seen_tier > cached {
seen_tier
} else if seen_tier < cached && saw_revocation {
seen_tier
} else {
cached
};
if !awards.is_empty() {
write_cached_award_ids(&active_ids);
}
if new_tier == cached {
return;
}
let _ = crate::db::set_sql_setting(
BADGE_BUG_HUNTER_TIER_KEY.to_string(),
new_tier.to_string(),
);
crate::log_info!("[Badges] bug hunter tier {} -> {}", cached, new_tier);
crate::traits::emit_event_json(
"badges_updated",
serde_json::json!({ "vector": has_vector_badge(), "tier": effective_tier(), "bug_hunter": bug_hunter_tier() }),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fawkes_claim_window_boundaries() {
assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START));
assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END - 1));
assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END));
assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START - 1));
assert!(!is_valid_fawkes_claim("", FAWKES_DAY_START));
assert!(!is_valid_fawkes_claim("something_else", FAWKES_DAY_START));
}
#[test]
fn bug_hunter_issuer_npub_is_valid() {
let _ = *BUG_HUNTER_ISSUER;
assert!(PublicKey::from_bech32(BUG_HUNTER_ISSUER_NPUB).is_ok());
}
#[test]
fn tier_from_coord_trusts_only_issuer_kind_and_slug() {
let issuer = "abc123";
assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-1", issuer), Some(1));
assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-2", issuer), Some(2));
assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-3", issuer), Some(3));
assert_eq!(tier_from_coord("30009:evil:bug-hunter-tier-3", issuer), None);
assert_eq!(tier_from_coord("30008:abc123:bug-hunter-tier-3", issuer), None);
assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-9", issuer), None);
}
#[test]
fn fold_bug_hunter_highest_non_revoked_with_revocation_flag() {
let id = |b: u8| EventId::from_hex(&format!("{:02x}", b).repeat(32)).unwrap();
assert_eq!(fold_bug_hunter(&[(id(1), 1), (id(3), 3), (id(2), 2)], &HashSet::new()), (3, false));
let revoked: HashSet<EventId> = [id(3)].into_iter().collect();
assert_eq!(fold_bug_hunter(&[(id(1), 1), (id(3), 3), (id(2), 2)], &revoked), (2, true));
assert_eq!(fold_bug_hunter(&[], &HashSet::new()), (0, false));
}
}