Skip to main content

nula_core/nips/
nip75.rs

1//! [NIP-75] Zap Goals.
2//!
3//! `kind: 9041` is a fundraising-goal event. The bundle pins the
4//! mandatory `amount` (in millisats) and `relays` columns, plus the
5//! optional `closed_at` deadline, `image`, `summary`, and reference
6//! tags (`r`/`a`). The spec also lets a goal embed NIP-57 `zap`
7//! tags to declare beneficiary pubkeys with split weights — we
8//! reuse [`crate::nips::nip57::ZapSplitTarget`] verbatim.
9//!
10//! On the consumer side, an addressable event can link back to a
11//! goal with a `goal` tag (`["goal", "<event-id>", "<relay>?"]`)
12//! that we model as [`GoalLink`].
13//!
14//! # Forward compatibility
15//!
16//! - Unknown tags survive a round-trip through [`ZapGoal::extra_tags`].
17//! - The `closed_at` timestamp uses [`Timestamp`], which already
18//!   tolerates negative deltas / future values.
19//! - Multiple `relays` columns are concatenated into a single tag
20//!   per spec example.
21//!
22//! [NIP-75]: https://github.com/nostr-protocol/nips/blob/master/75.md
23
24use thiserror::Error;
25
26use crate::event::{
27    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
28    SingleLetterTag, Tag, TagKind,
29};
30use crate::nips::nip57::{ZapError, ZapSplitTarget};
31use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError, Url, UrlError};
32
33/// `kind: 9041` — zap goal.
34pub const KIND_ZAP_GOAL: Kind = Kind::ZAP_GOAL;
35
36const AMOUNT_TAG: &str = "amount";
37const RELAYS_TAG: &str = "relays";
38const CLOSED_AT_TAG: &str = "closed_at";
39const IMAGE_TAG: &str = "image";
40const SUMMARY_TAG: &str = "summary";
41const GOAL_TAG: &str = "goal";
42
43/// Typed bundle for a `kind: 9041` zap goal event.
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct ZapGoal {
46    /// Target amount in millisats (`amount` tag — required).
47    pub amount_msats: u64,
48    /// Tally relays (`relays` tag — required). Spec mandates at
49    /// least one entry.
50    pub relays: Vec<RelayUrl>,
51    /// Human-readable description from `.content`.
52    pub content: String,
53    /// Optional deadline (`closed_at` tag).
54    pub closed_at: Option<Timestamp>,
55    /// Optional poster image (`image` tag).
56    pub image: Option<Url>,
57    /// Optional brief description (`summary` tag).
58    pub summary: Option<String>,
59    /// Optional `r` link (free-form URL).
60    pub url_link: Option<Url>,
61    /// Optional `a` link (addressable event).
62    pub address_link: Option<Coordinate>,
63    /// Beneficiary split targets (NIP-57 `zap` tags).
64    pub split_targets: Vec<ZapSplitTarget>,
65    /// Forward-compatible passthrough for unknown tags.
66    pub extra_tags: Vec<Tag>,
67}
68
69impl ZapGoal {
70    /// Construct a goal with the spec-required fields.
71    #[must_use]
72    pub fn new(amount_msats: u64, relays: Vec<RelayUrl>) -> Self {
73        Self {
74            amount_msats,
75            relays,
76            ..Self::default()
77        }
78    }
79
80    /// Set the human-readable description from `.content`.
81    #[must_use]
82    pub fn content(mut self, content: impl Into<String>) -> Self {
83        self.content = content.into();
84        self
85    }
86
87    /// Set [`Self::closed_at`].
88    #[must_use]
89    pub const fn closed_at(mut self, closed_at: Timestamp) -> Self {
90        self.closed_at = Some(closed_at);
91        self
92    }
93
94    /// Set [`Self::image`].
95    #[must_use]
96    pub fn image(mut self, url: Url) -> Self {
97        self.image = Some(url);
98        self
99    }
100
101    /// Set [`Self::summary`].
102    #[must_use]
103    pub fn summary(mut self, summary: impl Into<String>) -> Self {
104        self.summary = Some(summary.into());
105        self
106    }
107
108    /// Set [`Self::url_link`].
109    #[must_use]
110    pub fn url_link(mut self, url: Url) -> Self {
111        self.url_link = Some(url);
112        self
113    }
114
115    /// Set [`Self::address_link`].
116    #[must_use]
117    pub fn address_link(mut self, coordinate: Coordinate) -> Self {
118        self.address_link = Some(coordinate);
119        self
120    }
121
122    /// Append a NIP-57 split-target beneficiary.
123    #[must_use]
124    pub fn split_target(mut self, target: ZapSplitTarget) -> Self {
125        self.split_targets.push(target);
126        self
127    }
128
129    /// Parse a `kind: 9041` event into a typed bundle.
130    ///
131    /// # Errors
132    ///
133    /// - [`ZapGoalError::WrongKind`] for non-9041 events.
134    /// - [`ZapGoalError::MissingAmount`] / `MissingRelays` when a
135    ///   required tag is absent.
136    /// - Field-specific errors for malformed columns.
137    pub fn from_event(event: &Event) -> Result<Self, ZapGoalError> {
138        if event.kind != KIND_ZAP_GOAL {
139            return Err(ZapGoalError::WrongKind(event.kind));
140        }
141        let mut goal = Self {
142            content: event.content.clone(),
143            ..Self::default()
144        };
145        let mut saw_amount = false;
146        let mut saw_relays = false;
147        for tag in &event.tags {
148            match tag.kind() {
149                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::R => {
150                    let url_str = tag.get(1).ok_or(ZapGoalError::MalformedUrlLink)?;
151                    goal.url_link = Some(Url::parse(url_str)?);
152                }
153                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
154                    let coord_str = tag.get(1).ok_or(ZapGoalError::MalformedAddressLink)?;
155                    goal.address_link = Some(Coordinate::parse(coord_str)?);
156                }
157                _ if tag.name() == AMOUNT_TAG => {
158                    let raw = tag.get(1).ok_or(ZapGoalError::MalformedAmount)?;
159                    goal.amount_msats = raw
160                        .parse::<u64>()
161                        .map_err(|_| ZapGoalError::InvalidAmount(raw.to_owned()))?;
162                    saw_amount = true;
163                }
164                _ if tag.name() == RELAYS_TAG => {
165                    parse_relays_tag(tag, &mut goal.relays)?;
166                    saw_relays = true;
167                }
168                _ if tag.name() == CLOSED_AT_TAG => {
169                    let raw = tag.get(1).ok_or(ZapGoalError::MalformedClosedAt)?;
170                    goal.closed_at = Some(raw.parse::<Timestamp>()?);
171                }
172                _ if tag.name() == IMAGE_TAG => {
173                    let raw = tag.get(1).ok_or(ZapGoalError::MalformedImage)?;
174                    goal.image = Some(Url::parse(raw)?);
175                }
176                _ if tag.name() == SUMMARY_TAG => {
177                    goal.summary = tag.get(1).map(str::to_owned);
178                }
179                _ if tag.name() == "zap" => {
180                    goal.split_targets
181                        .push(ZapSplitTarget::from_tag(tag).map_err(ZapGoalError::Zap)?);
182                }
183                _ => goal.extra_tags.push(tag.clone()),
184            }
185        }
186        if !saw_amount {
187            return Err(ZapGoalError::MissingAmount);
188        }
189        if !saw_relays {
190            return Err(ZapGoalError::MissingRelays);
191        }
192        Ok(goal)
193    }
194}
195
196/// `goal` tag — embedded in addressable events to point back at a
197/// zap goal (spec §"Client behavior", second-to-last paragraph).
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct GoalLink {
200    /// Goal event id.
201    pub goal_event: EventId,
202    /// Optional relay hint.
203    pub relay_hint: Option<RelayUrl>,
204}
205
206impl GoalLink {
207    /// Construct a goal link without a relay hint.
208    #[must_use]
209    pub const fn new(goal_event: EventId) -> Self {
210        Self {
211            goal_event,
212            relay_hint: None,
213        }
214    }
215
216    /// Attach a relay hint.
217    #[must_use]
218    pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
219        self.relay_hint = Some(relay);
220        self
221    }
222
223    /// Render as a [`Tag`].
224    #[must_use]
225    pub fn to_tag(&self) -> Tag {
226        let head = TagKind::from_wire(GOAL_TAG);
227        self.relay_hint.as_ref().map_or_else(
228            || Tag::with(&head, [self.goal_event.to_hex()]),
229            |relay| Tag::with(&head, [self.goal_event.to_hex(), relay.as_str().to_owned()]),
230        )
231    }
232
233    /// Parse a `goal` tag back into a typed value.
234    ///
235    /// # Errors
236    ///
237    /// - [`ZapGoalError::WrongGoalTag`] when the tag's head is not
238    ///   `goal`.
239    /// - [`ZapGoalError::MalformedGoalTag`] when the event id is
240    ///   absent.
241    /// - [`ZapGoalError::InvalidEventId`] /
242    ///   [`ZapGoalError::InvalidRelayUrl`] for malformed columns.
243    pub fn from_tag(tag: &Tag) -> Result<Self, ZapGoalError> {
244        if tag.name() != GOAL_TAG {
245            return Err(ZapGoalError::WrongGoalTag);
246        }
247        let id_hex = tag.get(1).ok_or(ZapGoalError::MalformedGoalTag)?;
248        let goal_event = EventId::parse(id_hex)?;
249        let relay_hint = match tag.get(2) {
250            Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
251            _ => None,
252        };
253        Ok(Self {
254            goal_event,
255            relay_hint,
256        })
257    }
258}
259
260impl Tag {
261    /// Build a NIP-75 `goal` tag.
262    #[must_use]
263    pub fn goal(link: &GoalLink) -> Self {
264        link.to_tag()
265    }
266}
267
268/// Errors raised by NIP-75 parsers.
269#[derive(Debug, Error)]
270#[non_exhaustive]
271pub enum ZapGoalError {
272    /// The event was not `kind: 9041`.
273    #[error("expected kind 9041 (zap goal), got kind {}", .0.as_u16())]
274    WrongKind(Kind),
275    /// `amount` tag is absent.
276    #[error("zap goal missing `amount` tag")]
277    MissingAmount,
278    /// `relays` tag is absent.
279    #[error("zap goal missing `relays` tag")]
280    MissingRelays,
281    /// `amount` tag is missing its value column.
282    #[error("`amount` tag missing value")]
283    MalformedAmount,
284    /// `closed_at` tag is missing its value column.
285    #[error("`closed_at` tag missing value")]
286    MalformedClosedAt,
287    /// `image` tag is missing its URL column.
288    #[error("`image` tag missing URL")]
289    MalformedImage,
290    /// `r` link tag is missing its URL column.
291    #[error("`r` link tag missing URL")]
292    MalformedUrlLink,
293    /// `a` link tag is missing its coordinate column.
294    #[error("`a` link tag missing coordinate")]
295    MalformedAddressLink,
296    /// `amount` value is not a `u64`.
297    #[error("invalid amount value: `{0}`")]
298    InvalidAmount(String),
299    /// `closed_at` value is not a valid timestamp.
300    #[error(transparent)]
301    InvalidTimestamp(#[from] TimestampError),
302    /// Wrapped relay-url parser error.
303    #[error(transparent)]
304    InvalidRelayUrl(#[from] RelayUrlError),
305    /// Wrapped URL parser error.
306    #[error(transparent)]
307    InvalidUrl(#[from] UrlError),
308    /// Wrapped event-id parser error.
309    #[error(transparent)]
310    InvalidEventId(#[from] EventIdError),
311    /// Wrapped coordinate parser error.
312    #[error(transparent)]
313    InvalidCoordinate(#[from] CoordinateError),
314    /// NIP-57 split-target parsing failed.
315    #[error("zap split parse error: {0}")]
316    Zap(#[source] ZapError),
317    /// `goal` tag head was not `goal`.
318    #[error("expected `goal` tag")]
319    WrongGoalTag,
320    /// `goal` tag is missing the event-id column.
321    #[error("`goal` tag missing event id")]
322    MalformedGoalTag,
323}
324
325fn parse_relays_tag(tag: &Tag, relays: &mut Vec<RelayUrl>) -> Result<(), ZapGoalError> {
326    for v in tag.values().iter().skip(1) {
327        relays.push(RelayUrl::parse(v)?);
328    }
329    Ok(())
330}
331
332impl EventBuilder {
333    /// Author a NIP-75 `kind: 9041` zap goal event.
334    ///
335    /// # Panics
336    ///
337    /// Cannot panic in practice: the assembled `relays` tag always
338    /// includes its head before the relay URLs, so [`Tag::new`]'s
339    /// non-empty invariant always holds.
340    #[must_use]
341    pub fn zap_goal(goal: &ZapGoal) -> Self {
342        let mut builder = Self::new(KIND_ZAP_GOAL, goal.content.clone());
343        let mut relays_values: Vec<String> = Vec::with_capacity(goal.relays.len() + 1);
344        relays_values.push(RELAYS_TAG.to_owned());
345        for relay in &goal.relays {
346            relays_values.push(relay.as_str().to_owned());
347        }
348        let relays_tag = Tag::new(relays_values)
349            .unwrap_or_else(|_| unreachable!("`relays_values` always includes the tag head"));
350        builder = builder.tag(relays_tag);
351        builder = builder.tag(Tag::with(
352            &TagKind::from_wire(AMOUNT_TAG),
353            [goal.amount_msats.to_string()],
354        ));
355        if let Some(ts) = goal.closed_at {
356            builder = builder.tag(Tag::with(
357                &TagKind::from_wire(CLOSED_AT_TAG),
358                [ts.as_secs().to_string()],
359            ));
360        }
361        if let Some(url) = &goal.image {
362            builder = builder.tag(Tag::with(
363                &TagKind::from_wire(IMAGE_TAG),
364                [url.as_str().to_owned()],
365            ));
366        }
367        if let Some(summary) = &goal.summary {
368            builder = builder.tag(Tag::with(
369                &TagKind::from_wire(SUMMARY_TAG),
370                [summary.clone()],
371            ));
372        }
373        if let Some(url) = &goal.url_link {
374            let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R));
375            builder = builder.tag(Tag::with(&head, [url.as_str().to_owned()]));
376        }
377        if let Some(coord) = &goal.address_link {
378            builder = builder.tag(Tag::a(coord));
379        }
380        for target in &goal.split_targets {
381            builder = builder.tag(target.to_tag());
382        }
383        for tag in &goal.extra_tags {
384            builder = builder.tag(tag.clone());
385        }
386        builder
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::Keys;
394
395    fn keys() -> Keys {
396        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
397    }
398
399    fn other_pubkey() -> crate::PublicKey {
400        *Keys::parse("0000000000000000000000000000000000000000000000000000000000000004")
401            .unwrap()
402            .public_key()
403    }
404
405    fn relay() -> RelayUrl {
406        RelayUrl::parse("wss://alice.example/").unwrap()
407    }
408
409    fn relay_other() -> RelayUrl {
410        RelayUrl::parse("wss://bob.example/").unwrap()
411    }
412
413    #[test]
414    fn round_trip_minimal_goal() {
415        let goal = ZapGoal::new(210_000, vec![relay()]).content("Nostrasia travel");
416        let event = EventBuilder::zap_goal(&goal)
417            .sign_with_keys(&keys())
418            .unwrap();
419        assert_eq!(event.kind, KIND_ZAP_GOAL);
420        let parsed = ZapGoal::from_event(&event).unwrap();
421        assert_eq!(parsed, goal);
422    }
423
424    #[test]
425    fn round_trip_full_goal() {
426        let coord = Coordinate::new(Kind::new(30_023), *keys().public_key(), "post".to_owned());
427        let goal = ZapGoal::new(500_000, vec![relay(), relay_other()])
428            .content("Help me reach the goal")
429            .closed_at(Timestamp::from_secs(1_700_000_000))
430            .image(Url::parse("https://example.com/poster.png").unwrap())
431            .summary("Short description")
432            .url_link(Url::parse("https://example.com/").unwrap())
433            .address_link(coord)
434            .split_target(ZapSplitTarget::new(other_pubkey()).weight(1));
435        let event = EventBuilder::zap_goal(&goal)
436            .sign_with_keys(&keys())
437            .unwrap();
438        let parsed = ZapGoal::from_event(&event).unwrap();
439        assert_eq!(parsed, goal);
440    }
441
442    #[test]
443    fn wrong_kind_is_rejected() {
444        let event = EventBuilder::text_note("nope")
445            .sign_with_keys(&keys())
446            .unwrap();
447        assert!(matches!(
448            ZapGoal::from_event(&event),
449            Err(ZapGoalError::WrongKind(_))
450        ));
451    }
452
453    #[test]
454    fn missing_amount_is_rejected() {
455        let event = EventBuilder::new(KIND_ZAP_GOAL, "")
456            .tag(Tag::new(vec![RELAYS_TAG.to_owned(), relay().as_str().to_owned()]).unwrap())
457            .sign_with_keys(&keys())
458            .unwrap();
459        assert!(matches!(
460            ZapGoal::from_event(&event),
461            Err(ZapGoalError::MissingAmount)
462        ));
463    }
464
465    #[test]
466    fn missing_relays_is_rejected() {
467        let event = EventBuilder::new(KIND_ZAP_GOAL, "")
468            .tag(Tag::with(&TagKind::from_wire(AMOUNT_TAG), ["100"]))
469            .sign_with_keys(&keys())
470            .unwrap();
471        assert!(matches!(
472            ZapGoal::from_event(&event),
473            Err(ZapGoalError::MissingRelays)
474        ));
475    }
476
477    #[test]
478    fn goal_link_round_trip() {
479        let link = GoalLink::new(EventId::from_byte_array([0xaa; 32])).relay_hint(relay());
480        let tag = link.to_tag();
481        assert_eq!(tag.name(), GOAL_TAG);
482        let parsed = GoalLink::from_tag(&tag).unwrap();
483        assert_eq!(parsed, link);
484    }
485
486    #[test]
487    fn goal_link_without_relay_hint() {
488        let link = GoalLink::new(EventId::from_byte_array([0xbb; 32]));
489        let tag = link.to_tag();
490        let parsed = GoalLink::from_tag(&tag).unwrap();
491        assert_eq!(parsed, link);
492    }
493
494    #[test]
495    fn goal_link_wrong_head_rejected() {
496        let tag = Tag::title("not a goal tag");
497        assert!(matches!(
498            GoalLink::from_tag(&tag),
499            Err(ZapGoalError::WrongGoalTag)
500        ));
501    }
502}