1use 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
33pub 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct ZapGoal {
46 pub amount_msats: u64,
48 pub relays: Vec<RelayUrl>,
51 pub content: String,
53 pub closed_at: Option<Timestamp>,
55 pub image: Option<Url>,
57 pub summary: Option<String>,
59 pub url_link: Option<Url>,
61 pub address_link: Option<Coordinate>,
63 pub split_targets: Vec<ZapSplitTarget>,
65 pub extra_tags: Vec<Tag>,
67}
68
69impl ZapGoal {
70 #[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 #[must_use]
82 pub fn content(mut self, content: impl Into<String>) -> Self {
83 self.content = content.into();
84 self
85 }
86
87 #[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 #[must_use]
96 pub fn image(mut self, url: Url) -> Self {
97 self.image = Some(url);
98 self
99 }
100
101 #[must_use]
103 pub fn summary(mut self, summary: impl Into<String>) -> Self {
104 self.summary = Some(summary.into());
105 self
106 }
107
108 #[must_use]
110 pub fn url_link(mut self, url: Url) -> Self {
111 self.url_link = Some(url);
112 self
113 }
114
115 #[must_use]
117 pub fn address_link(mut self, coordinate: Coordinate) -> Self {
118 self.address_link = Some(coordinate);
119 self
120 }
121
122 #[must_use]
124 pub fn split_target(mut self, target: ZapSplitTarget) -> Self {
125 self.split_targets.push(target);
126 self
127 }
128
129 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#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct GoalLink {
200 pub goal_event: EventId,
202 pub relay_hint: Option<RelayUrl>,
204}
205
206impl GoalLink {
207 #[must_use]
209 pub const fn new(goal_event: EventId) -> Self {
210 Self {
211 goal_event,
212 relay_hint: None,
213 }
214 }
215
216 #[must_use]
218 pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
219 self.relay_hint = Some(relay);
220 self
221 }
222
223 #[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 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 #[must_use]
263 pub fn goal(link: &GoalLink) -> Self {
264 link.to_tag()
265 }
266}
267
268#[derive(Debug, Error)]
270#[non_exhaustive]
271pub enum ZapGoalError {
272 #[error("expected kind 9041 (zap goal), got kind {}", .0.as_u16())]
274 WrongKind(Kind),
275 #[error("zap goal missing `amount` tag")]
277 MissingAmount,
278 #[error("zap goal missing `relays` tag")]
280 MissingRelays,
281 #[error("`amount` tag missing value")]
283 MalformedAmount,
284 #[error("`closed_at` tag missing value")]
286 MalformedClosedAt,
287 #[error("`image` tag missing URL")]
289 MalformedImage,
290 #[error("`r` link tag missing URL")]
292 MalformedUrlLink,
293 #[error("`a` link tag missing coordinate")]
295 MalformedAddressLink,
296 #[error("invalid amount value: `{0}`")]
298 InvalidAmount(String),
299 #[error(transparent)]
301 InvalidTimestamp(#[from] TimestampError),
302 #[error(transparent)]
304 InvalidRelayUrl(#[from] RelayUrlError),
305 #[error(transparent)]
307 InvalidUrl(#[from] UrlError),
308 #[error(transparent)]
310 InvalidEventId(#[from] EventIdError),
311 #[error(transparent)]
313 InvalidCoordinate(#[from] CoordinateError),
314 #[error("zap split parse error: {0}")]
316 Zap(#[source] ZapError),
317 #[error("expected `goal` tag")]
319 WrongGoalTag,
320 #[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 #[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}