Skip to main content

vector_core/
webxdc.rs

1//! WebXDC Mini App helpers shared across transports (DM + Community).
2//!
3//! The realtime-channel topic for a Mini App is minted ONCE at send time and
4//! carried on the file event as a `webxdc-topic` tag, so every participant
5//! joins the SAME gossip topic. Locally-derived topics are asymmetric in DMs
6//! (each side's chat_id is the other party's npub), which silently splits the
7//! players onto disjoint topics — the tag is the single source of truth.
8
9/// Mint a fresh realtime-channel topic id for an outbound `.xdc` attachment.
10///
11/// 32 bytes of SHA-256 over a domain separator + file hash + sender + send-time
12/// nanos, encoded base32 (RFC 4648, no padding) — the same codec the miniapp
13/// realtime layer's `decode_topic_id` expects, so the tag value round-trips
14/// into an iroh `TopicId`. The nanos input makes re-sends of the same file
15/// distinct sessions.
16pub fn mint_topic_id(file_hash: &str, sender_hex: &str) -> String {
17    use sha2::{Digest, Sha256};
18    let nanos = std::time::SystemTime::now()
19        .duration_since(std::time::UNIX_EPOCH)
20        .map(|d| d.as_nanos())
21        .unwrap_or(0);
22    let mut hasher = Sha256::new();
23    hasher.update(b"webxdc-realtime-v1:");
24    hasher.update(file_hash.as_bytes());
25    hasher.update(b":");
26    hasher.update(sender_hex.as_bytes());
27    hasher.update(b":");
28    hasher.update(nanos.to_le_bytes());
29    base32_nopad_encode(&hasher.finalize())
30}
31
32/// BASE32 no-pad encoding (RFC 4648). Mirrors the miniapp realtime layer's
33/// codec exactly — the two must agree for topic tags to decode.
34pub fn base32_nopad_encode(bytes: &[u8]) -> String {
35    const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
36    let mut out = String::with_capacity((bytes.len() * 8 + 4) / 5);
37    let mut buf: u64 = 0;
38    let mut bits: u32 = 0;
39    for &b in bytes {
40        buf = (buf << 8) | b as u64;
41        bits += 8;
42        while bits >= 5 {
43            bits -= 5;
44            out.push(ALPHABET[((buf >> bits) & 0x1F) as usize] as char);
45        }
46    }
47    if bits > 0 {
48        out.push(ALPHABET[((buf << (5 - bits)) & 0x1F) as usize] as char);
49    }
50    out
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn minted_topic_is_32_bytes_base32() {
59        let t = mint_topic_id("abc123", "deadbeef");
60        // 32 bytes → ceil(256/5) = 52 base32 chars
61        assert_eq!(t.len(), 52);
62        assert!(t.chars().all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
63    }
64
65    #[test]
66    fn resends_mint_distinct_topics() {
67        let a = mint_topic_id("abc123", "deadbeef");
68        let b = mint_topic_id("abc123", "deadbeef");
69        assert_ne!(a, b, "same file re-sent must start a fresh session topic");
70    }
71
72    #[test]
73    fn base32_matches_rfc4648_vectors() {
74        // RFC 4648 §10 test vectors (padding stripped)
75        assert_eq!(base32_nopad_encode(b""), "");
76        assert_eq!(base32_nopad_encode(b"f"), "MY");
77        assert_eq!(base32_nopad_encode(b"fo"), "MZXQ");
78        assert_eq!(base32_nopad_encode(b"foo"), "MZXW6");
79        assert_eq!(base32_nopad_encode(b"foob"), "MZXW6YQ");
80        assert_eq!(base32_nopad_encode(b"fooba"), "MZXW6YTB");
81        assert_eq!(base32_nopad_encode(b"foobar"), "MZXW6YTBOI");
82    }
83}