Skip to main content

nula_core/nips/
nip58.rs

1//! [NIP-58] Badges.
2//!
3//! Four kinds wire up the badge ecosystem:
4//!
5//! | Kind   | Type        | Purpose                                  |
6//! |--------|-------------|------------------------------------------|
7//! | 30009  | Addressable | Badge definition (immutable design)      |
8//! | 8      | Regular     | Badge award (issuer → awardee bundle)    |
9//! | 10008  | Replaceable | Profile badges list (chosen-by-recipient)|
10//! | 30008  | Addressable | Badge sets (organising chosen badges)    |
11//!
12//! # Why a typed module
13//!
14//! Upstream `rust-nostr` has only a thin `Tag::badge_*` helper for
15//! profile-badge tag building; everything else is hand-rolled. We
16//! ship the full set:
17//!
18//! - [`BadgeDefinition`] — the kind 30009 bundle with `name`,
19//!   `description`, full-resolution `image`, and any number of
20//!   `thumb` variants. The image carries optional NIP-94-style
21//!   dimensions.
22//! - [`BadgeAward`] — the kind 8 bundle with the addressable
23//!   coordinate of the definition plus one or more awardee `p`
24//!   tags (with optional relay hints).
25//! - [`ProfileBadges`] — the kind 10008 list. The spec says
26//!   "ordered consecutive pairs of `a` and `e` tags"; we surface
27//!   that as `Vec<ProfileBadgeEntry>` with a strong invariant
28//!   (every entry has both halves) and the reader silently drops
29//!   orphaned single-tag rows the spec also tells us to ignore.
30//!
31//! Spec §"Deprecated Profile Badges event" recognises the old
32//! `kind: 30008` + `d=profile_badges` form: [`ProfileBadges::from_event`]
33//! accepts both kinds for forward compatibility, and surfaces the
34//! detected form via [`ProfileBadgesSource`].
35//!
36//! [NIP-58]: https://github.com/nostr-protocol/nips/blob/master/58.md
37
38use thiserror::Error;
39
40use crate::event::{
41    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
42    SingleLetterTag, Tag, TagKind, Tags,
43};
44use crate::key::{PublicKey, PublicKeyError};
45use crate::types::{ImageDimensions, ImageError, RelayUrl, RelayUrlError};
46
47/// `kind: 30009` — badge definition.
48pub const KIND_BADGE_DEFINITION: Kind = Kind::BADGE_DEFINITION;
49/// `kind: 8` — badge award.
50pub const KIND_BADGE_AWARD: Kind = Kind::BADGE_AWARD;
51/// `kind: 10008` — profile badges list (modern form).
52pub const KIND_PROFILE_BADGES: Kind = Kind::PROFILE_BADGES;
53/// `kind: 30008` — deprecated profile badges (`d = "profile_badges"`).
54pub const KIND_BADGE_SET: Kind = Kind::BADGE_SET;
55/// `d`-tag value used by the deprecated `kind: 30008` profile-badges
56/// form (NIP-58 §"Deprecated Profile Badges event").
57pub const DEPRECATED_PROFILE_BADGES_IDENTIFIER: &str = "profile_badges";
58
59/// One image variant inside a badge — the high-res `image` or any
60/// `thumb` row.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct BadgeImage {
63    /// Image URL.
64    pub url: String,
65    /// Optional `<width>x<height>`.
66    pub dim: Option<ImageDimensions>,
67}
68
69impl BadgeImage {
70    /// Construct without dimensions.
71    #[must_use]
72    pub fn new(url: impl Into<String>) -> Self {
73        Self {
74            url: url.into(),
75            dim: None,
76        }
77    }
78
79    /// Set dimensions.
80    #[must_use]
81    pub const fn dim(mut self, dim: ImageDimensions) -> Self {
82        self.dim = Some(dim);
83        self
84    }
85
86    fn to_tag(&self, name: &str) -> Tag {
87        let mut values: Vec<String> = Vec::with_capacity(2);
88        values.push(self.url.clone());
89        if let Some(dim) = self.dim {
90            values.push(dim.to_string());
91        }
92        custom_tag(name, values)
93    }
94
95    fn from_tag(tag: &Tag) -> Result<Option<Self>, BadgeError> {
96        let Some(url) = tag.get(1) else {
97            return Ok(None);
98        };
99        let mut img = Self::new(url);
100        if let Some(d) = tag.get(2)
101            && !d.is_empty()
102        {
103            img.dim = Some(
104                d.parse::<ImageDimensions>()
105                    .map_err(BadgeError::InvalidImageDim)?,
106            );
107        }
108        Ok(Some(img))
109    }
110}
111
112/// Typed bundle for a `kind: 30009` badge definition.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct BadgeDefinition {
115    /// `d` tag — unique slug (`bravery`, …).
116    pub identifier: String,
117    /// `name` tag — short display name.
118    pub name: Option<String>,
119    /// `description` tag.
120    pub description: Option<String>,
121    /// `image` tag (high-res).
122    pub image: Option<BadgeImage>,
123    /// `thumb` tags (zero or more variants).
124    pub thumbnails: Vec<BadgeImage>,
125}
126
127impl BadgeDefinition {
128    /// Construct an empty definition.
129    #[must_use]
130    pub fn new(identifier: impl Into<String>) -> Self {
131        Self {
132            identifier: identifier.into(),
133            name: None,
134            description: None,
135            image: None,
136            thumbnails: Vec::new(),
137        }
138    }
139
140    /// Set [`Self::name`].
141    #[must_use]
142    pub fn name(mut self, name: impl Into<String>) -> Self {
143        self.name = Some(name.into());
144        self
145    }
146
147    /// Set [`Self::description`].
148    #[must_use]
149    pub fn description(mut self, description: impl Into<String>) -> Self {
150        self.description = Some(description.into());
151        self
152    }
153
154    /// Set [`Self::image`].
155    #[must_use]
156    pub fn image(mut self, image: BadgeImage) -> Self {
157        self.image = Some(image);
158        self
159    }
160
161    /// Append one [`thumbnails`](Self::thumbnails) variant.
162    #[must_use]
163    pub fn thumbnail(mut self, thumb: BadgeImage) -> Self {
164        self.thumbnails.push(thumb);
165        self
166    }
167
168    /// Render to the tag list of a `kind: 30009` event.
169    #[must_use]
170    pub fn to_tags(&self) -> Vec<Tag> {
171        let mut tags: Vec<Tag> = Vec::with_capacity(4 + self.thumbnails.len());
172        tags.push(Tag::d(&self.identifier));
173        if let Some(name) = &self.name {
174            tags.push(custom_tag("name", [name.clone()]));
175        }
176        if let Some(desc) = &self.description {
177            tags.push(custom_tag("description", [desc.clone()]));
178        }
179        if let Some(img) = &self.image {
180            tags.push(img.to_tag("image"));
181        }
182        for thumb in &self.thumbnails {
183            tags.push(thumb.to_tag("thumb"));
184        }
185        tags
186    }
187
188    /// Parse a `kind: 30009` event back into a typed bundle.
189    ///
190    /// # Errors
191    ///
192    /// - [`BadgeError::WrongKind`] for any other kind.
193    /// - [`BadgeError::MissingIdentifier`] when no `d` tag.
194    /// - [`BadgeError::InvalidImageDim`] when an image dimension
195    ///   token is malformed.
196    pub fn from_event(event: &Event) -> Result<Self, BadgeError> {
197        if event.kind != KIND_BADGE_DEFINITION {
198            return Err(BadgeError::WrongKind(event.kind));
199        }
200        let identifier = identifier_value(&event.tags)
201            .ok_or(BadgeError::MissingIdentifier)?
202            .to_owned();
203        let mut def = Self::new(identifier);
204        for tag in &event.tags {
205            match tag.kind() {
206                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
207                TagKind::Custom(name) if name == "name" => {
208                    def.name = tag.get(1).map(str::to_owned);
209                }
210                TagKind::Custom(name) if name == "description" => {
211                    def.description = tag.get(1).map(str::to_owned);
212                }
213                TagKind::Custom(name) if name == "image" => {
214                    def.image = BadgeImage::from_tag(tag)?;
215                }
216                TagKind::Custom(name) if name == "thumb" => {
217                    push_thumbnail(tag, &mut def.thumbnails)?;
218                }
219                _ => {}
220            }
221        }
222        Ok(def)
223    }
224
225    /// Build the badge's addressable coordinate.
226    #[must_use]
227    pub fn coordinate(&self, issuer: PublicKey) -> Coordinate {
228        Coordinate::new(KIND_BADGE_DEFINITION, issuer, self.identifier.clone())
229    }
230}
231
232/// One awardee entry inside a badge award.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct BadgeAwardee {
235    /// Awardee pubkey.
236    pub pubkey: PublicKey,
237    /// Optional relay hint where the awardee can be found.
238    pub relay_hint: Option<RelayUrl>,
239}
240
241impl BadgeAwardee {
242    /// Construct without a relay hint.
243    #[must_use]
244    pub const fn new(pubkey: PublicKey) -> Self {
245        Self {
246            pubkey,
247            relay_hint: None,
248        }
249    }
250
251    /// Set the relay hint.
252    #[must_use]
253    pub fn relay_hint(mut self, hint: RelayUrl) -> Self {
254        self.relay_hint = Some(hint);
255        self
256    }
257}
258
259/// Typed bundle for a `kind: 8` badge award.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct BadgeAward {
262    /// Coordinate of the badge definition (`a` tag value).
263    pub definition: Coordinate,
264    /// Awardees (one or more `p` tags). Spec mandates `>= 1`.
265    pub awardees: Vec<BadgeAwardee>,
266}
267
268impl BadgeAward {
269    /// Construct an award with one awardee. Use
270    /// [`Self::awardee`] to chain more.
271    #[must_use]
272    pub fn new(definition: Coordinate, awardee: BadgeAwardee) -> Self {
273        Self {
274            definition,
275            awardees: vec![awardee],
276        }
277    }
278
279    /// Add another awardee.
280    #[must_use]
281    pub fn awardee(mut self, awardee: BadgeAwardee) -> Self {
282        self.awardees.push(awardee);
283        self
284    }
285
286    /// Render to the tag list of a `kind: 8` event.
287    #[must_use]
288    pub fn to_tags(&self) -> Vec<Tag> {
289        let mut tags: Vec<Tag> = Vec::with_capacity(1 + self.awardees.len());
290        tags.push(letter_tag(Alphabet::A, [self.definition.to_wire()]));
291        for awardee in &self.awardees {
292            let mut values: Vec<String> = Vec::with_capacity(2);
293            values.push(awardee.pubkey.to_hex());
294            if let Some(relay) = &awardee.relay_hint {
295                values.push(relay.as_str().to_owned());
296            }
297            tags.push(letter_tag(Alphabet::P, values));
298        }
299        tags
300    }
301
302    /// Parse a `kind: 8` event.
303    ///
304    /// # Errors
305    ///
306    /// - [`BadgeError::WrongKind`] for any other kind.
307    /// - [`BadgeError::MissingDefinition`] when no `a` tag.
308    /// - [`BadgeError::MissingAwardee`] when no `p` tag.
309    /// - Forwarded parse errors for malformed coordinates / pubkeys.
310    pub fn from_event(event: &Event) -> Result<Self, BadgeError> {
311        if event.kind != KIND_BADGE_AWARD {
312            return Err(BadgeError::WrongKind(event.kind));
313        }
314        let mut definition: Option<Coordinate> = None;
315        let mut awardees: Vec<BadgeAwardee> = Vec::new();
316        for tag in &event.tags {
317            match tag.kind() {
318                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
319                    let coord_str = tag.get(1).ok_or(BadgeError::MalformedDefinition)?;
320                    definition =
321                        Some(Coordinate::parse(coord_str).map_err(BadgeError::InvalidCoordinate)?);
322                }
323                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
324                    let pk_hex = tag.get(1).ok_or(BadgeError::MalformedAwardee)?;
325                    let pubkey = PublicKey::parse(pk_hex).map_err(BadgeError::InvalidPublicKey)?;
326                    let relay_hint = parse_optional_relay(tag.get(2))?;
327                    awardees.push(BadgeAwardee { pubkey, relay_hint });
328                }
329                _ => {}
330            }
331        }
332        let definition = definition.ok_or(BadgeError::MissingDefinition)?;
333        if awardees.is_empty() {
334            return Err(BadgeError::MissingAwardee);
335        }
336        Ok(Self {
337            definition,
338            awardees,
339        })
340    }
341}
342
343/// One row inside a profile-badges list — paired (`a`, `e`) tags.
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct ProfileBadgeEntry {
346    /// Coordinate of the badge definition (`a` tag).
347    pub definition: Coordinate,
348    /// Optional relay hint accompanying the `a` tag.
349    pub definition_relay: Option<RelayUrl>,
350    /// Event id of the matching badge award (`e` tag).
351    pub award: EventId,
352    /// Optional relay hint accompanying the `e` tag.
353    pub award_relay: Option<RelayUrl>,
354}
355
356impl ProfileBadgeEntry {
357    /// Construct an entry without relay hints.
358    #[must_use]
359    pub const fn new(definition: Coordinate, award: EventId) -> Self {
360        Self {
361            definition,
362            definition_relay: None,
363            award,
364            award_relay: None,
365        }
366    }
367
368    /// Set the relay hint on the `a` tag.
369    #[must_use]
370    pub fn definition_relay(mut self, hint: RelayUrl) -> Self {
371        self.definition_relay = Some(hint);
372        self
373    }
374
375    /// Set the relay hint on the `e` tag.
376    #[must_use]
377    pub fn award_relay(mut self, hint: RelayUrl) -> Self {
378        self.award_relay = Some(hint);
379        self
380    }
381}
382
383/// Did the profile-badges event arrive in the modern form
384/// (`kind: 10008`) or in the deprecated `kind: 30008` /
385/// `d=profile_badges` form?
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum ProfileBadgesSource {
388    /// Modern `kind: 10008`.
389    Replaceable,
390    /// Deprecated `kind: 30008` + `d = profile_badges`.
391    DeprecatedAddressable,
392}
393
394/// Typed bundle for a profile-badges list.
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct ProfileBadges {
397    /// Ordered (`a`, `e`) pairs.
398    pub entries: Vec<ProfileBadgeEntry>,
399}
400
401impl ProfileBadges {
402    /// Empty list.
403    #[must_use]
404    pub const fn new() -> Self {
405        Self {
406            entries: Vec::new(),
407        }
408    }
409
410    /// Append one entry.
411    #[must_use]
412    pub fn entry(mut self, entry: ProfileBadgeEntry) -> Self {
413        self.entries.push(entry);
414        self
415    }
416
417    /// Render to the tag list of a `kind: 10008` event.
418    #[must_use]
419    pub fn to_tags(&self) -> Vec<Tag> {
420        let mut tags: Vec<Tag> = Vec::with_capacity(self.entries.len() * 2);
421        for entry in &self.entries {
422            let mut a_values: Vec<String> = Vec::with_capacity(2);
423            a_values.push(entry.definition.to_wire());
424            if let Some(r) = &entry.definition_relay {
425                a_values.push(r.as_str().to_owned());
426            }
427            tags.push(letter_tag(Alphabet::A, a_values));
428            let mut e_values: Vec<String> = Vec::with_capacity(2);
429            e_values.push(entry.award.to_hex());
430            if let Some(r) = &entry.award_relay {
431                e_values.push(r.as_str().to_owned());
432            }
433            tags.push(letter_tag(Alphabet::E, e_values));
434        }
435        tags
436    }
437
438    /// Parse a `kind: 10008` (modern) or `kind: 30008` (deprecated)
439    /// profile-badges event into a typed bundle.
440    ///
441    /// Orphaned `a` / `e` tags (without their pair) are silently
442    /// skipped per spec §"Profile Badges Event": *"Clients SHOULD
443    /// ignore `a` without corresponding `e` tag and viceversa"*.
444    ///
445    /// # Errors
446    ///
447    /// - [`BadgeError::WrongKind`] for unrelated kinds.
448    /// - Forwarded parse errors when an `a` / `e` value is
449    ///   malformed.
450    pub fn from_event(event: &Event) -> Result<(Self, ProfileBadgesSource), BadgeError> {
451        let source = match event.kind {
452            KIND_PROFILE_BADGES => ProfileBadgesSource::Replaceable,
453            KIND_BADGE_SET
454                if event
455                    .tags
456                    .identifier()
457                    .is_some_and(|d| d == DEPRECATED_PROFILE_BADGES_IDENTIFIER) =>
458            {
459                ProfileBadgesSource::DeprecatedAddressable
460            }
461            other => return Err(BadgeError::WrongKind(other)),
462        };
463        let mut entries: Vec<ProfileBadgeEntry> = Vec::new();
464        let mut pending_a: Option<(Coordinate, Option<RelayUrl>)> = None;
465        for tag in &event.tags {
466            match tag.kind() {
467                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
468                    let coord_str = tag.get(1).ok_or(BadgeError::MalformedDefinition)?;
469                    let coord =
470                        Coordinate::parse(coord_str).map_err(BadgeError::InvalidCoordinate)?;
471                    let relay = parse_optional_relay(tag.get(2))?;
472                    pending_a = Some((coord, relay));
473                }
474                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
475                    pair_award_with_pending(tag, &mut pending_a, &mut entries)?;
476                }
477                _ => {}
478            }
479        }
480        Ok((Self { entries }, source))
481    }
482}
483
484impl Default for ProfileBadges {
485    fn default() -> Self {
486        Self::new()
487    }
488}
489
490fn identifier_value(tags: &Tags) -> Option<&str> {
491    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
492    tags.find_first(&head).and_then(|tag| tag.get(1))
493}
494
495fn push_thumbnail(tag: &Tag, thumbnails: &mut Vec<BadgeImage>) -> Result<(), BadgeError> {
496    if let Some(img) = BadgeImage::from_tag(tag)? {
497        thumbnails.push(img);
498    }
499    Ok(())
500}
501
502fn pair_award_with_pending(
503    tag: &Tag,
504    pending_a: &mut Option<(Coordinate, Option<RelayUrl>)>,
505    entries: &mut Vec<ProfileBadgeEntry>,
506) -> Result<(), BadgeError> {
507    let id_hex = tag.get(1).ok_or(BadgeError::MalformedAward)?;
508    let award = EventId::parse(id_hex).map_err(BadgeError::InvalidEventId)?;
509    let award_relay = parse_optional_relay(tag.get(2))?;
510    // Orphaned `e` (no preceding `a`) is silently dropped per spec.
511    if let Some((definition, definition_relay)) = pending_a.take() {
512        entries.push(ProfileBadgeEntry {
513            definition,
514            definition_relay,
515            award,
516            award_relay,
517        });
518    }
519    Ok(())
520}
521
522fn parse_optional_relay(value: Option<&str>) -> Result<Option<RelayUrl>, BadgeError> {
523    match value {
524        Some(s) if !s.is_empty() => Ok(Some(
525            RelayUrl::parse(s).map_err(BadgeError::InvalidRelayUrl)?,
526        )),
527        _ => Ok(None),
528    }
529}
530
531fn letter_tag<I, S>(alphabet: Alphabet, args: I) -> Tag
532where
533    I: IntoIterator<Item = S>,
534    S: Into<String>,
535{
536    let head = TagKind::single_letter(SingleLetterTag::lowercase(alphabet));
537    Tag::with(&head, args)
538}
539
540fn custom_tag<I, S>(name: &str, args: I) -> Tag
541where
542    I: IntoIterator<Item = S>,
543    S: Into<String>,
544{
545    Tag::with(&TagKind::Custom(name.to_owned()), args)
546}
547
548/// Errors raised while building or parsing badge events.
549#[derive(Debug, Error)]
550#[non_exhaustive]
551pub enum BadgeError {
552    /// The event was not the kind expected by the parser.
553    #[error("unexpected kind {}", .0.as_u16())]
554    WrongKind(Kind),
555    /// `kind: 30009` event was missing its `d` identifier.
556    #[error("badge definition is missing the `d` identifier tag")]
557    MissingIdentifier,
558    /// `kind: 8` event was missing the `a` definition tag.
559    #[error("badge award must carry an `a` definition tag")]
560    MissingDefinition,
561    /// `kind: 8` event had no `p` awardee tags.
562    #[error("badge award must carry at least one `p` awardee tag")]
563    MissingAwardee,
564    /// `a` definition tag was missing its coordinate column.
565    #[error("malformed `a` definition tag")]
566    MalformedDefinition,
567    /// `p` awardee tag was missing its pubkey column.
568    #[error("malformed `p` awardee tag")]
569    MalformedAwardee,
570    /// `e` award tag was missing its event-id column.
571    #[error("malformed `e` award tag")]
572    MalformedAward,
573    /// Coordinate parse failure.
574    #[error("invalid coordinate: {0}")]
575    InvalidCoordinate(#[source] CoordinateError),
576    /// Pubkey parse failure.
577    #[error("invalid public key: {0}")]
578    InvalidPublicKey(#[source] PublicKeyError),
579    /// Event id parse failure.
580    #[error("invalid event id: {0}")]
581    InvalidEventId(#[source] EventIdError),
582    /// Relay URL parse failure.
583    #[error("invalid relay URL: {0}")]
584    InvalidRelayUrl(#[source] RelayUrlError),
585    /// `image` / `thumb` dimensions parse failure.
586    #[error("invalid image dimensions: {0}")]
587    InvalidImageDim(#[source] ImageError),
588}
589
590impl EventBuilder {
591    /// Author a NIP-58 badge definition (`kind: 30009`).
592    #[must_use]
593    pub fn badge_definition(definition: &BadgeDefinition) -> Self {
594        let mut builder = Self::new(KIND_BADGE_DEFINITION, "");
595        for tag in definition.to_tags() {
596            builder = builder.tag(tag);
597        }
598        builder
599    }
600
601    /// Author a NIP-58 badge award (`kind: 8`).
602    #[must_use]
603    pub fn badge_award(award: &BadgeAward) -> Self {
604        let mut builder = Self::new(KIND_BADGE_AWARD, "");
605        for tag in award.to_tags() {
606            builder = builder.tag(tag);
607        }
608        builder
609    }
610
611    /// Author a NIP-58 profile-badges list (`kind: 10008`).
612    #[must_use]
613    pub fn profile_badges(badges: &ProfileBadges) -> Self {
614        let mut builder = Self::new(KIND_PROFILE_BADGES, "");
615        for tag in badges.to_tags() {
616            builder = builder.tag(tag);
617        }
618        builder
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::Keys;
626
627    fn keys() -> Keys {
628        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
629    }
630
631    fn other_keys() -> Keys {
632        Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
633    }
634
635    #[test]
636    fn badge_definition_round_trips() {
637        let def = BadgeDefinition::new("bravery")
638            .name("Medal of Bravery")
639            .description("Awarded to users demonstrating bravery")
640            .image(
641                BadgeImage::new("https://example.com/bravery.png")
642                    .dim(ImageDimensions::new(1024, 1024).unwrap()),
643            )
644            .thumbnail(
645                BadgeImage::new("https://example.com/bravery_256.png")
646                    .dim(ImageDimensions::new(256, 256).unwrap()),
647            )
648            .thumbnail(BadgeImage::new("https://example.com/bravery_64.png"));
649        let event = EventBuilder::badge_definition(&def)
650            .sign_with_keys(&keys())
651            .unwrap();
652        assert_eq!(event.kind, KIND_BADGE_DEFINITION);
653        let parsed = BadgeDefinition::from_event(&event).unwrap();
654        assert_eq!(parsed, def);
655    }
656
657    #[test]
658    fn badge_definition_requires_d_tag() {
659        let event = EventBuilder::new(KIND_BADGE_DEFINITION, "")
660            .sign_with_keys(&keys())
661            .unwrap();
662        assert!(matches!(
663            BadgeDefinition::from_event(&event),
664            Err(BadgeError::MissingIdentifier)
665        ));
666    }
667
668    #[test]
669    fn badge_award_round_trips_multiple_awardees() {
670        let issuer = *keys().public_key();
671        let definition = Coordinate::new(KIND_BADGE_DEFINITION, issuer, "bravery");
672        let award = BadgeAward::new(definition, BadgeAwardee::new(*other_keys().public_key()))
673            .awardee(
674                BadgeAwardee::new(issuer)
675                    .relay_hint(RelayUrl::parse("wss://relay.example/").unwrap()),
676            );
677        let event = EventBuilder::badge_award(&award)
678            .sign_with_keys(&keys())
679            .unwrap();
680        let parsed = BadgeAward::from_event(&event).unwrap();
681        assert_eq!(parsed, award);
682    }
683
684    #[test]
685    fn badge_award_requires_definition_and_awardee() {
686        let event_no_a = EventBuilder::new(KIND_BADGE_AWARD, "")
687            .tag(letter_tag(Alphabet::P, [keys().public_key().to_hex()]))
688            .sign_with_keys(&keys())
689            .unwrap();
690        assert!(matches!(
691            BadgeAward::from_event(&event_no_a),
692            Err(BadgeError::MissingDefinition)
693        ));
694
695        let coord = Coordinate::new(KIND_BADGE_DEFINITION, *keys().public_key(), "x");
696        let event_no_p = EventBuilder::new(KIND_BADGE_AWARD, "")
697            .tag(letter_tag(Alphabet::A, [coord.to_wire()]))
698            .sign_with_keys(&keys())
699            .unwrap();
700        assert!(matches!(
701            BadgeAward::from_event(&event_no_p),
702            Err(BadgeError::MissingAwardee)
703        ));
704    }
705
706    #[test]
707    fn profile_badges_round_trip_pairs() {
708        let issuer = *keys().public_key();
709        let coord1 = Coordinate::new(KIND_BADGE_DEFINITION, issuer, "bravery");
710        let coord2 = Coordinate::new(KIND_BADGE_DEFINITION, issuer, "honor");
711        let id1 = EventId::from_byte_array([0x10; 32]);
712        let id2 = EventId::from_byte_array([0x20; 32]);
713        let badges = ProfileBadges::new()
714            .entry(
715                ProfileBadgeEntry::new(coord1, id1)
716                    .award_relay(RelayUrl::parse("wss://nostr.academy/").unwrap()),
717            )
718            .entry(ProfileBadgeEntry::new(coord2, id2));
719        let event = EventBuilder::profile_badges(&badges)
720            .sign_with_keys(&other_keys())
721            .unwrap();
722        let (parsed, source) = ProfileBadges::from_event(&event).unwrap();
723        assert_eq!(source, ProfileBadgesSource::Replaceable);
724        assert_eq!(parsed, badges);
725    }
726
727    #[test]
728    fn profile_badges_drops_orphaned_e_tag() {
729        let issuer = *keys().public_key();
730        let coord = Coordinate::new(KIND_BADGE_DEFINITION, issuer, "bravery");
731        let id1 = EventId::from_byte_array([0x10; 32]);
732        let orphan_id = EventId::from_byte_array([0x99; 32]);
733        // `e` before any `a` — must be dropped.
734        let event = EventBuilder::new(KIND_PROFILE_BADGES, "")
735            .tag(letter_tag(Alphabet::E, [orphan_id.to_hex()]))
736            .tag(letter_tag(Alphabet::A, [coord.to_wire()]))
737            .tag(letter_tag(Alphabet::E, [id1.to_hex()]))
738            .sign_with_keys(&other_keys())
739            .unwrap();
740        let (parsed, _) = ProfileBadges::from_event(&event).unwrap();
741        assert_eq!(parsed.entries.len(), 1);
742        assert_eq!(parsed.entries[0].award, id1);
743    }
744
745    #[test]
746    fn profile_badges_recognises_deprecated_kind() {
747        let issuer = *keys().public_key();
748        let coord = Coordinate::new(KIND_BADGE_DEFINITION, issuer, "bravery");
749        let id = EventId::from_byte_array([0xab; 32]);
750        let event = EventBuilder::new(KIND_BADGE_SET, "")
751            .tag(Tag::d(DEPRECATED_PROFILE_BADGES_IDENTIFIER))
752            .tag(letter_tag(Alphabet::A, [coord.to_wire()]))
753            .tag(letter_tag(Alphabet::E, [id.to_hex()]))
754            .sign_with_keys(&other_keys())
755            .unwrap();
756        let (parsed, source) = ProfileBadges::from_event(&event).unwrap();
757        assert_eq!(source, ProfileBadgesSource::DeprecatedAddressable);
758        assert_eq!(parsed.entries.len(), 1);
759    }
760
761    #[test]
762    fn profile_badges_rejects_addressable_30008_without_marker() {
763        // kind 30008 with a different `d` is a regular badge set,
764        // NOT a deprecated profile-badges list.
765        let event = EventBuilder::new(KIND_BADGE_SET, "")
766            .tag(Tag::d("some-other-set"))
767            .sign_with_keys(&other_keys())
768            .unwrap();
769        assert!(matches!(
770            ProfileBadges::from_event(&event),
771            Err(BadgeError::WrongKind(_))
772        ));
773    }
774
775    #[test]
776    fn coordinate_helper_uses_issuer_pubkey() {
777        let def = BadgeDefinition::new("bravery");
778        let coord = def.coordinate(*keys().public_key());
779        assert_eq!(coord.kind, KIND_BADGE_DEFINITION);
780        assert_eq!(coord.identifier, "bravery");
781    }
782}