1#![allow(
46 clippy::excessive_nesting,
47 reason = "the per-tag match-on-name dispatch pattern in `from_event` keeps the wire-format-to-field mapping at the surface; flattening obscures it"
48)]
49
50use thiserror::Error;
51
52use crate::event::{
53 Alphabet, Event, EventBuilder, EventBuilderError, EventId, EventIdError, Kind, SingleLetterTag,
54 Tag, TagError, TagKind,
55};
56use crate::key::{PublicKey, PublicKeyError};
57use crate::types::{RelayUrl, RelayUrlError};
58
59pub const KIND_JOB_FEEDBACK: Kind = Kind::new(7_000);
61pub const JOB_REQUEST_RANGE_START: u16 = 5_000;
63pub const JOB_REQUEST_RANGE_END: u16 = 5_999;
65pub const JOB_RESULT_RANGE_START: u16 = 6_000;
67pub const JOB_RESULT_RANGE_END: u16 = 6_999;
69pub const REQUEST_TO_RESULT_OFFSET: u16 = 1_000;
71
72mod tag_names {
73 pub(super) const I: &str = "i";
74 pub(super) const OUTPUT: &str = "output";
75 pub(super) const PARAM: &str = "param";
76 pub(super) const BID: &str = "bid";
77 pub(super) const RELAYS: &str = "relays";
78 pub(super) const T: &str = "t";
79 pub(super) const REQUEST: &str = "request";
80 pub(super) const AMOUNT: &str = "amount";
81 pub(super) const STATUS: &str = "status";
82 pub(super) const ENCRYPTED: &str = "encrypted";
83}
84
85mod input_kinds {
86 pub(super) const URL: &str = "url";
87 pub(super) const EVENT: &str = "event";
88 pub(super) const JOB: &str = "job";
89 pub(super) const TEXT: &str = "text";
90}
91
92mod feedback_strings {
93 pub(super) const PAYMENT_REQUIRED: &str = "payment-required";
94 pub(super) const PROCESSING: &str = "processing";
95 pub(super) const ERROR: &str = "error";
96 pub(super) const SUCCESS: &str = "success";
97 pub(super) const PARTIAL: &str = "partial";
98}
99
100#[must_use]
103pub const fn is_job_request_kind(kind: Kind) -> bool {
104 matches!(
105 kind.as_u16(),
106 JOB_REQUEST_RANGE_START..=JOB_REQUEST_RANGE_END
107 )
108}
109
110#[must_use]
113pub const fn is_job_result_kind(kind: Kind) -> bool {
114 matches!(kind.as_u16(), JOB_RESULT_RANGE_START..=JOB_RESULT_RANGE_END)
115}
116
117#[must_use]
121pub const fn result_kind_for(request_kind: Kind) -> Option<Kind> {
122 if is_job_request_kind(request_kind) {
123 Some(Kind::new(request_kind.as_u16() + REQUEST_TO_RESULT_OFFSET))
124 } else {
125 None
126 }
127}
128
129#[must_use]
133pub const fn request_kind_for(result_kind: Kind) -> Option<Kind> {
134 if is_job_result_kind(result_kind) {
135 Some(Kind::new(result_kind.as_u16() - REQUEST_TO_RESULT_OFFSET))
136 } else {
137 None
138 }
139}
140
141#[derive(Debug, Error)]
143#[non_exhaustive]
144pub enum Nip90Error {
145 #[error("DVM job-request kind {0} is outside `5000..=5999`")]
147 InvalidRequestKind(Kind),
148 #[error("DVM job-result kind {0} is outside `6000..=6999`")]
150 InvalidResultKind(Kind),
151 #[error("expected kind 7000, got {0}")]
153 InvalidFeedbackKind(Kind),
154 #[error("result kind {got} does not match request kind {request} + 1000 = {expected}")]
156 KindMismatch {
157 request: Kind,
159 expected: Kind,
161 got: Kind,
163 },
164 #[error("DVM `i` tag has unknown marker `{0}` (expected url/event/job/text)")]
167 UnknownInputKind(String),
168 #[error("DVM millisat value `{0}` is not a valid u64")]
170 MalformedMillisats(String),
171 #[error("DVM `param` tag missing value column")]
173 MalformedParam,
174 #[error(transparent)]
176 PublicKey(#[from] PublicKeyError),
177 #[error(transparent)]
179 RelayUrl(#[from] RelayUrlError),
180 #[error(transparent)]
182 EventId(#[from] EventIdError),
183 #[error(transparent)]
185 Tag(#[from] TagError),
186 #[error(transparent)]
188 Builder(#[from] EventBuilderError),
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
197#[non_exhaustive]
198pub enum JobInput {
199 Url(String),
201 Event {
203 event_id: EventId,
205 relay: Option<RelayUrl>,
207 },
208 Job {
210 event_id: EventId,
212 relay: Option<RelayUrl>,
214 },
215 Text(String),
217}
218
219impl JobInput {
220 fn render(&self, marker: Option<&str>) -> Vec<String> {
224 let mut row: Vec<String> = match self {
225 Self::Url(url) => vec![url.clone(), input_kinds::URL.to_owned(), String::new()],
226 Self::Text(text) => vec![text.clone(), input_kinds::TEXT.to_owned(), String::new()],
227 Self::Event { event_id, relay } => vec![
228 event_id.to_hex(),
229 input_kinds::EVENT.to_owned(),
230 relay
231 .as_ref()
232 .map(|r| r.as_str().to_owned())
233 .unwrap_or_default(),
234 ],
235 Self::Job { event_id, relay } => vec![
236 event_id.to_hex(),
237 input_kinds::JOB.to_owned(),
238 relay
239 .as_ref()
240 .map(|r| r.as_str().to_owned())
241 .unwrap_or_default(),
242 ],
243 };
244 if let Some(marker) = marker {
245 row.push(marker.to_owned());
246 }
247 row
248 }
249
250 fn parse(args: &[String]) -> Result<(Self, Option<String>), Nip90Error> {
253 let value = args.first().cloned().unwrap_or_default();
254 let kind = args
255 .get(1)
256 .cloned()
257 .unwrap_or_else(|| input_kinds::URL.to_owned());
258 let relay = args.get(2).and_then(|s| {
259 if s.is_empty() {
260 None
261 } else {
262 Some(RelayUrl::parse(s))
263 }
264 });
265 let marker = args.get(3).cloned();
266 let input = match kind.as_str() {
267 input_kinds::URL => Self::Url(value),
268 input_kinds::TEXT => Self::Text(value),
269 input_kinds::EVENT => Self::Event {
270 event_id: EventId::parse(&value)?,
271 relay: relay.transpose()?,
272 },
273 input_kinds::JOB => Self::Job {
274 event_id: EventId::parse(&value)?,
275 relay: relay.transpose()?,
276 },
277 other => return Err(Nip90Error::UnknownInputKind(other.to_owned())),
278 };
279 Ok((input, marker))
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct JobInputRef {
286 pub input: JobInput,
288 pub marker: Option<String>,
290}
291
292impl JobInputRef {
293 #[must_use]
295 pub const fn new(input: JobInput) -> Self {
296 Self {
297 input,
298 marker: None,
299 }
300 }
301
302 #[must_use]
304 pub fn marker(mut self, marker: impl Into<String>) -> Self {
305 self.marker = Some(marker.into());
306 self
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct JobParam {
313 pub key: String,
315 pub value: String,
317}
318
319impl JobParam {
320 #[must_use]
322 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
323 Self {
324 key: key.into(),
325 value: value.into(),
326 }
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct Amount {
334 pub msats: u64,
336 pub bolt11: Option<String>,
338}
339
340impl Amount {
341 #[must_use]
343 pub const fn new(msats: u64) -> Self {
344 Self {
345 msats,
346 bolt11: None,
347 }
348 }
349
350 #[must_use]
352 pub fn invoice(mut self, bolt11: impl Into<String>) -> Self {
353 self.bolt11 = Some(bolt11.into());
354 self
355 }
356
357 fn render(&self) -> Vec<String> {
358 let mut row = vec![self.msats.to_string()];
359 if let Some(invoice) = &self.bolt11 {
360 row.push(invoice.clone());
361 }
362 row
363 }
364
365 fn parse(args: &[String]) -> Result<Self, Nip90Error> {
366 let raw = args
367 .first()
368 .ok_or_else(|| Nip90Error::MalformedMillisats(String::new()))?;
369 let msats: u64 = raw
370 .parse()
371 .map_err(|_| Nip90Error::MalformedMillisats(raw.clone()))?;
372 let bolt11 = args.get(1).cloned();
373 Ok(Self { msats, bolt11 })
374 }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct JobRequest {
380 pub kind: Kind,
382 pub content: String,
386 pub inputs: Vec<JobInputRef>,
388 pub output: Option<String>,
390 pub params: Vec<JobParam>,
392 pub bid_msats: Option<u64>,
394 pub relays: Vec<RelayUrl>,
396 pub topics: Vec<String>,
398 pub providers: Vec<PublicKey>,
400 pub encrypted: bool,
403}
404
405impl JobRequest {
406 pub const fn new(kind: Kind) -> Result<Self, Nip90Error> {
413 if !is_job_request_kind(kind) {
414 return Err(Nip90Error::InvalidRequestKind(kind));
415 }
416 Ok(Self {
417 kind,
418 content: String::new(),
419 inputs: Vec::new(),
420 output: None,
421 params: Vec::new(),
422 bid_msats: None,
423 relays: Vec::new(),
424 topics: Vec::new(),
425 providers: Vec::new(),
426 encrypted: false,
427 })
428 }
429
430 #[must_use]
432 pub fn content(mut self, content: impl Into<String>) -> Self {
433 self.content = content.into();
434 self
435 }
436
437 #[must_use]
439 pub fn input(mut self, input: JobInputRef) -> Self {
440 self.inputs.push(input);
441 self
442 }
443
444 #[must_use]
446 pub fn param(mut self, param: JobParam) -> Self {
447 self.params.push(param);
448 self
449 }
450
451 #[must_use]
453 pub fn output(mut self, output: impl Into<String>) -> Self {
454 self.output = Some(output.into());
455 self
456 }
457
458 #[must_use]
460 pub const fn bid_msats(mut self, msats: u64) -> Self {
461 self.bid_msats = Some(msats);
462 self
463 }
464
465 #[must_use]
467 pub fn relay(mut self, url: RelayUrl) -> Self {
468 self.relays.push(url);
469 self
470 }
471
472 #[must_use]
474 pub fn topic(mut self, topic: impl Into<String>) -> Self {
475 self.topics.push(topic.into());
476 self
477 }
478
479 #[must_use]
481 pub fn provider(mut self, provider: PublicKey) -> Self {
482 self.providers.push(provider);
483 self
484 }
485
486 #[must_use]
489 pub const fn encrypted(mut self, encrypted: bool) -> Self {
490 self.encrypted = encrypted;
491 self
492 }
493
494 #[must_use]
496 pub fn to_tags(&self) -> Vec<Tag> {
497 let mut tags: Vec<Tag> = Vec::new();
498 for input in &self.inputs {
499 tags.push(Tag::with(
500 &TagKind::custom(tag_names::I),
501 input.input.render(input.marker.as_deref()),
502 ));
503 }
504 if let Some(output) = &self.output {
505 tags.push(Tag::with(
506 &TagKind::custom(tag_names::OUTPUT),
507 [output.clone()],
508 ));
509 }
510 for param in &self.params {
511 tags.push(Tag::with(
512 &TagKind::custom(tag_names::PARAM),
513 [param.key.clone(), param.value.clone()],
514 ));
515 }
516 if let Some(bid) = self.bid_msats {
517 tags.push(Tag::with(
518 &TagKind::custom(tag_names::BID),
519 [bid.to_string()],
520 ));
521 }
522 if !self.relays.is_empty() {
523 let mut row = vec![tag_names::RELAYS.to_owned()];
524 for relay in &self.relays {
525 row.push(relay.as_str().to_owned());
526 }
527 row.remove(0);
530 tags.push(Tag::with(&TagKind::custom(tag_names::RELAYS), row));
531 }
532 for topic in &self.topics {
533 tags.push(Tag::with(&TagKind::custom(tag_names::T), [topic.clone()]));
534 }
535 for provider in &self.providers {
536 tags.push(Tag::with(
537 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
538 [provider.to_hex()],
539 ));
540 }
541 if self.encrypted {
542 tags.push(Tag::with(
543 &TagKind::custom(tag_names::ENCRYPTED),
544 Vec::<String>::new(),
545 ));
546 }
547 tags
548 }
549
550 pub fn from_event(event: &Event) -> Result<Self, Nip90Error> {
558 if !is_job_request_kind(event.kind) {
559 return Err(Nip90Error::InvalidRequestKind(event.kind));
560 }
561 let mut req = Self::new(event.kind)?;
562 req.content.clone_from(&event.content);
563 for tag in &event.tags {
564 let values = tag.values();
565 let args = values.get(1..).unwrap_or(&[]);
566 match tag.name() {
567 tag_names::I => {
568 let (input, marker) = JobInput::parse(args)?;
569 req.inputs.push(JobInputRef { input, marker });
570 }
571 tag_names::OUTPUT => {
572 if let Some(value) = args.first() {
573 req.output = Some(value.clone());
574 }
575 }
576 tag_names::PARAM => {
577 let key = args.first().cloned().ok_or(Nip90Error::MalformedParam)?;
578 let value = args.get(1).cloned().ok_or(Nip90Error::MalformedParam)?;
579 req.params.push(JobParam { key, value });
580 }
581 tag_names::BID => {
582 if let Some(value) = args.first() {
583 let bid: u64 = value
584 .parse()
585 .map_err(|_| Nip90Error::MalformedMillisats(value.clone()))?;
586 req.bid_msats = Some(bid);
587 }
588 }
589 tag_names::RELAYS => {
590 for raw in args {
591 req.relays.push(RelayUrl::parse(raw)?);
592 }
593 }
594 tag_names::T => {
595 if let Some(value) = args.first() {
596 req.topics.push(value.clone());
597 }
598 }
599 "p" => {
600 if let Some(value) = args.first() {
601 req.providers.push(PublicKey::parse(value)?);
602 }
603 }
604 tag_names::ENCRYPTED => req.encrypted = true,
605 _ => {}
606 }
607 }
608 Ok(req)
609 }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq)]
614pub struct JobResult {
615 pub kind: Kind,
617 pub content: String,
620 pub request_json: Option<String>,
623 pub request_event: Option<EventId>,
625 pub request_relay: Option<RelayUrl>,
627 pub customer: Option<PublicKey>,
629 pub inputs: Vec<JobInputRef>,
631 pub amount: Option<Amount>,
633 pub encrypted: bool,
635}
636
637impl JobResult {
638 pub const fn new(kind: Kind) -> Result<Self, Nip90Error> {
645 if !is_job_result_kind(kind) {
646 return Err(Nip90Error::InvalidResultKind(kind));
647 }
648 Ok(Self {
649 kind,
650 content: String::new(),
651 request_json: None,
652 request_event: None,
653 request_relay: None,
654 customer: None,
655 inputs: Vec::new(),
656 amount: None,
657 encrypted: false,
658 })
659 }
660
661 #[must_use]
663 pub fn content(mut self, content: impl Into<String>) -> Self {
664 self.content = content.into();
665 self
666 }
667
668 #[must_use]
670 pub fn request_json(mut self, json: impl Into<String>) -> Self {
671 self.request_json = Some(json.into());
672 self
673 }
674
675 #[must_use]
677 pub fn request_event(mut self, event: EventId, relay: Option<RelayUrl>) -> Self {
678 self.request_event = Some(event);
679 self.request_relay = relay;
680 self
681 }
682
683 #[must_use]
685 pub const fn customer(mut self, customer: PublicKey) -> Self {
686 self.customer = Some(customer);
687 self
688 }
689
690 #[must_use]
692 pub fn input(mut self, input: JobInputRef) -> Self {
693 self.inputs.push(input);
694 self
695 }
696
697 #[must_use]
699 pub fn amount(mut self, amount: Amount) -> Self {
700 self.amount = Some(amount);
701 self
702 }
703
704 #[must_use]
706 pub const fn encrypted(mut self, encrypted: bool) -> Self {
707 self.encrypted = encrypted;
708 self
709 }
710
711 #[must_use]
713 pub fn to_tags(&self) -> Vec<Tag> {
714 let mut tags: Vec<Tag> = Vec::new();
715 if let Some(json) = &self.request_json {
716 tags.push(Tag::with(
717 &TagKind::custom(tag_names::REQUEST),
718 [json.clone()],
719 ));
720 }
721 if let Some(event_id) = self.request_event {
722 let mut row = vec![event_id.to_hex()];
723 if let Some(relay) = &self.request_relay {
724 row.push(relay.as_str().to_owned());
725 }
726 tags.push(Tag::with(
727 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
728 row,
729 ));
730 }
731 for input in &self.inputs {
732 tags.push(Tag::with(
733 &TagKind::custom(tag_names::I),
734 input.input.render(input.marker.as_deref()),
735 ));
736 }
737 if let Some(customer) = self.customer {
738 tags.push(Tag::with(
739 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
740 [customer.to_hex()],
741 ));
742 }
743 if let Some(amount) = &self.amount {
744 tags.push(Tag::with(
745 &TagKind::custom(tag_names::AMOUNT),
746 amount.render(),
747 ));
748 }
749 if self.encrypted {
750 tags.push(Tag::with(
751 &TagKind::custom(tag_names::ENCRYPTED),
752 Vec::<String>::new(),
753 ));
754 }
755 tags
756 }
757
758 pub fn from_event(event: &Event) -> Result<Self, Nip90Error> {
766 if !is_job_result_kind(event.kind) {
767 return Err(Nip90Error::InvalidResultKind(event.kind));
768 }
769 let mut result = Self::new(event.kind)?;
770 result.content.clone_from(&event.content);
771 for tag in &event.tags {
772 let values = tag.values();
773 let args = values.get(1..).unwrap_or(&[]);
774 match tag.name() {
775 tag_names::REQUEST => {
776 if let Some(json) = args.first() {
777 result.request_json = Some(json.clone());
778 }
779 }
780 "e" => {
781 if let Some(id_hex) = args.first() {
782 result.request_event = Some(EventId::parse(id_hex)?);
783 }
784 if let Some(relay) = args.get(1)
785 && !relay.is_empty()
786 {
787 result.request_relay = Some(RelayUrl::parse(relay)?);
788 }
789 }
790 tag_names::I => {
791 let (input, marker) = JobInput::parse(args)?;
792 result.inputs.push(JobInputRef { input, marker });
793 }
794 "p" => {
795 if let Some(value) = args.first() {
796 result.customer = Some(PublicKey::parse(value)?);
797 }
798 }
799 tag_names::AMOUNT => {
800 result.amount = Some(Amount::parse(args)?);
801 }
802 tag_names::ENCRYPTED => result.encrypted = true,
803 _ => {}
804 }
805 }
806 Ok(result)
807 }
808}
809
810#[derive(Debug, Clone, PartialEq, Eq)]
812#[non_exhaustive]
813pub enum FeedbackStatus {
814 PaymentRequired,
816 Processing,
818 Error,
820 Success,
822 Partial,
824 Custom(String),
826}
827
828impl FeedbackStatus {
829 #[must_use]
831 pub const fn as_str(&self) -> &str {
832 match self {
833 Self::PaymentRequired => feedback_strings::PAYMENT_REQUIRED,
834 Self::Processing => feedback_strings::PROCESSING,
835 Self::Error => feedback_strings::ERROR,
836 Self::Success => feedback_strings::SUCCESS,
837 Self::Partial => feedback_strings::PARTIAL,
838 Self::Custom(s) => s.as_str(),
839 }
840 }
841
842 #[must_use]
845 pub fn from_wire(s: &str) -> Self {
846 match s {
847 feedback_strings::PAYMENT_REQUIRED => Self::PaymentRequired,
848 feedback_strings::PROCESSING => Self::Processing,
849 feedback_strings::ERROR => Self::Error,
850 feedback_strings::SUCCESS => Self::Success,
851 feedback_strings::PARTIAL => Self::Partial,
852 other => Self::Custom(other.to_owned()),
853 }
854 }
855}
856
857#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct JobFeedback {
860 pub content: String,
862 pub status: FeedbackStatus,
864 pub status_extra: Option<String>,
866 pub amount: Option<Amount>,
868 pub request_event: Option<EventId>,
870 pub request_relay: Option<RelayUrl>,
872 pub customer: Option<PublicKey>,
874}
875
876impl JobFeedback {
877 #[must_use]
879 pub const fn new(status: FeedbackStatus) -> Self {
880 Self {
881 content: String::new(),
882 status,
883 status_extra: None,
884 amount: None,
885 request_event: None,
886 request_relay: None,
887 customer: None,
888 }
889 }
890
891 #[must_use]
893 pub fn content(mut self, content: impl Into<String>) -> Self {
894 self.content = content.into();
895 self
896 }
897
898 #[must_use]
900 pub fn status_extra(mut self, extra: impl Into<String>) -> Self {
901 self.status_extra = Some(extra.into());
902 self
903 }
904
905 #[must_use]
907 pub fn amount(mut self, amount: Amount) -> Self {
908 self.amount = Some(amount);
909 self
910 }
911
912 #[must_use]
914 pub fn request_event(mut self, event: EventId, relay: Option<RelayUrl>) -> Self {
915 self.request_event = Some(event);
916 self.request_relay = relay;
917 self
918 }
919
920 #[must_use]
922 pub const fn customer(mut self, customer: PublicKey) -> Self {
923 self.customer = Some(customer);
924 self
925 }
926
927 #[must_use]
929 pub fn to_tags(&self) -> Vec<Tag> {
930 let mut tags: Vec<Tag> = Vec::new();
931 let mut status_row = vec![self.status.as_str().to_owned()];
932 if let Some(extra) = &self.status_extra {
933 status_row.push(extra.clone());
934 }
935 tags.push(Tag::with(&TagKind::custom(tag_names::STATUS), status_row));
936 if let Some(amount) = &self.amount {
937 tags.push(Tag::with(
938 &TagKind::custom(tag_names::AMOUNT),
939 amount.render(),
940 ));
941 }
942 if let Some(event_id) = self.request_event {
943 let mut row = vec![event_id.to_hex()];
944 if let Some(relay) = &self.request_relay {
945 row.push(relay.as_str().to_owned());
946 }
947 tags.push(Tag::with(
948 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
949 row,
950 ));
951 }
952 if let Some(customer) = self.customer {
953 tags.push(Tag::with(
954 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
955 [customer.to_hex()],
956 ));
957 }
958 tags
959 }
960
961 pub fn from_event(event: &Event) -> Result<Self, Nip90Error> {
969 if event.kind != KIND_JOB_FEEDBACK {
970 return Err(Nip90Error::InvalidFeedbackKind(event.kind));
971 }
972 let mut feedback = Self::new(FeedbackStatus::Custom(String::new()));
973 feedback.content.clone_from(&event.content);
974 for tag in &event.tags {
975 let values = tag.values();
976 let args = values.get(1..).unwrap_or(&[]);
977 match tag.name() {
978 tag_names::STATUS => {
979 if let Some(value) = args.first() {
980 feedback.status = FeedbackStatus::from_wire(value);
981 }
982 feedback.status_extra = args.get(1).cloned();
983 }
984 tag_names::AMOUNT => {
985 feedback.amount = Some(Amount::parse(args)?);
986 }
987 "e" => {
988 if let Some(id_hex) = args.first() {
989 feedback.request_event = Some(EventId::parse(id_hex)?);
990 }
991 if let Some(relay) = args.get(1)
992 && !relay.is_empty()
993 {
994 feedback.request_relay = Some(RelayUrl::parse(relay)?);
995 }
996 }
997 "p" => {
998 if let Some(value) = args.first() {
999 feedback.customer = Some(PublicKey::parse(value)?);
1000 }
1001 }
1002 _ => {}
1003 }
1004 }
1005 Ok(feedback)
1006 }
1007}
1008
1009impl EventBuilder {
1010 #[must_use]
1012 pub fn dvm_job_request(request: &JobRequest) -> Self {
1013 let mut builder = Self::new(request.kind, request.content.clone());
1014 for tag in request.to_tags() {
1015 builder = builder.tag(tag);
1016 }
1017 builder
1018 }
1019
1020 #[must_use]
1022 pub fn dvm_job_result(result: &JobResult) -> Self {
1023 let mut builder = Self::new(result.kind, result.content.clone());
1024 for tag in result.to_tags() {
1025 builder = builder.tag(tag);
1026 }
1027 builder
1028 }
1029
1030 #[must_use]
1033 pub fn dvm_job_feedback(feedback: &JobFeedback) -> Self {
1034 let mut builder = Self::new(KIND_JOB_FEEDBACK, feedback.content.clone());
1035 for tag in feedback.to_tags() {
1036 builder = builder.tag(tag);
1037 }
1038 builder
1039 }
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::*;
1045 use crate::Keys;
1046
1047 fn keys() -> Keys {
1048 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
1049 }
1050
1051 fn other_keys() -> Keys {
1052 Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
1053 }
1054
1055 fn relay() -> RelayUrl {
1056 RelayUrl::parse("wss://relay.example/").unwrap()
1057 }
1058
1059 #[test]
1060 fn kind_helpers_round_trip() {
1061 let req = Kind::new(5_001);
1062 let res = result_kind_for(req).unwrap();
1063 assert_eq!(res, Kind::new(6_001));
1064 assert_eq!(request_kind_for(res), Some(req));
1065 assert!(is_job_request_kind(req));
1066 assert!(is_job_result_kind(res));
1067 assert!(!is_job_request_kind(res));
1068 assert!(result_kind_for(Kind::TEXT_NOTE).is_none());
1069 assert!(request_kind_for(Kind::new(7_000)).is_none());
1070 }
1071
1072 #[test]
1073 fn job_request_round_trips_through_event() {
1074 let request = JobRequest::new(Kind::new(5_001))
1075 .unwrap()
1076 .input(JobInputRef::new(JobInput::Text("hello".to_owned())).marker("prompt"))
1077 .input(JobInputRef::new(JobInput::Url(
1078 "https://example.com/data".to_owned(),
1079 )))
1080 .output("text/plain")
1081 .param(JobParam::new("model", "LLaMA-2"))
1082 .param(JobParam::new("temperature", "0.5"))
1083 .bid_msats(21_000)
1084 .relay(relay())
1085 .topic("bitcoin")
1086 .provider(*other_keys().public_key());
1087 let event = EventBuilder::dvm_job_request(&request)
1088 .sign_with_keys(&keys())
1089 .unwrap();
1090 assert_eq!(event.kind, Kind::new(5_001));
1091 let recovered = JobRequest::from_event(&event).unwrap();
1092 assert_eq!(recovered, request);
1093 }
1094
1095 #[test]
1096 fn job_request_new_rejects_kind_outside_range() {
1097 assert!(matches!(
1098 JobRequest::new(Kind::TEXT_NOTE),
1099 Err(Nip90Error::InvalidRequestKind(_)),
1100 ));
1101 assert!(matches!(
1102 JobRequest::new(Kind::new(6_000)),
1103 Err(Nip90Error::InvalidRequestKind(_)),
1104 ));
1105 }
1106
1107 #[test]
1108 fn job_request_input_kinds_round_trip() {
1109 let request = JobRequest::new(Kind::new(5_002))
1110 .unwrap()
1111 .input(JobInputRef::new(JobInput::Event {
1112 event_id: EventId::from_byte_array([0xaa; 32]),
1113 relay: Some(relay()),
1114 }))
1115 .input(JobInputRef::new(JobInput::Job {
1116 event_id: EventId::from_byte_array([0xbb; 32]),
1117 relay: None,
1118 }))
1119 .input(JobInputRef::new(JobInput::Text("hi".to_owned())));
1120 let event = EventBuilder::dvm_job_request(&request)
1121 .sign_with_keys(&keys())
1122 .unwrap();
1123 let recovered = JobRequest::from_event(&event).unwrap();
1124 assert_eq!(recovered.inputs, request.inputs);
1125 }
1126
1127 #[test]
1128 fn job_request_encrypted_marker_round_trips() {
1129 let request = JobRequest::new(Kind::new(5_050))
1130 .unwrap()
1131 .content("ciphertext")
1132 .encrypted(true);
1133 let event = EventBuilder::dvm_job_request(&request)
1134 .sign_with_keys(&keys())
1135 .unwrap();
1136 let has_marker = event.tags.iter().any(|t| t.name() == "encrypted");
1137 assert!(has_marker);
1138 let recovered = JobRequest::from_event(&event).unwrap();
1139 assert!(recovered.encrypted);
1140 }
1141
1142 #[test]
1143 fn job_result_round_trips_through_event() {
1144 let result = JobResult::new(Kind::new(6_001))
1145 .unwrap()
1146 .content("translation output")
1147 .request_json("{\"id\":\"abc\"}")
1148 .request_event(EventId::from_byte_array([0x11; 32]), Some(relay()))
1149 .customer(*keys().public_key())
1150 .input(JobInputRef::new(JobInput::Url(
1151 "https://example.com".to_owned(),
1152 )))
1153 .amount(Amount::new(10_000).invoice("lnbc1..."));
1154 let event = EventBuilder::dvm_job_result(&result)
1155 .sign_with_keys(&other_keys())
1156 .unwrap();
1157 assert_eq!(event.kind, Kind::new(6_001));
1158 let recovered = JobResult::from_event(&event).unwrap();
1159 assert_eq!(recovered, result);
1160 }
1161
1162 #[test]
1163 fn job_result_new_rejects_kind_outside_range() {
1164 assert!(matches!(
1165 JobResult::new(Kind::TEXT_NOTE),
1166 Err(Nip90Error::InvalidResultKind(_)),
1167 ));
1168 assert!(matches!(
1169 JobResult::new(Kind::new(5_001)),
1170 Err(Nip90Error::InvalidResultKind(_)),
1171 ));
1172 }
1173
1174 #[test]
1175 fn job_feedback_round_trips_through_event() {
1176 let feedback = JobFeedback::new(FeedbackStatus::PaymentRequired)
1177 .status_extra("Please pay 21 sats")
1178 .amount(Amount::new(21_000).invoice("lnbc..."))
1179 .request_event(EventId::from_byte_array([0x22; 32]), Some(relay()))
1180 .customer(*keys().public_key())
1181 .content("partial sample");
1182 let event = EventBuilder::dvm_job_feedback(&feedback)
1183 .sign_with_keys(&other_keys())
1184 .unwrap();
1185 assert_eq!(event.kind, KIND_JOB_FEEDBACK);
1186 let recovered = JobFeedback::from_event(&event).unwrap();
1187 assert_eq!(recovered, feedback);
1188 }
1189
1190 #[test]
1191 fn job_feedback_status_round_trips_through_wire_form() {
1192 for status in [
1193 FeedbackStatus::PaymentRequired,
1194 FeedbackStatus::Processing,
1195 FeedbackStatus::Error,
1196 FeedbackStatus::Success,
1197 FeedbackStatus::Partial,
1198 FeedbackStatus::Custom("queued".to_owned()),
1199 ] {
1200 assert_eq!(FeedbackStatus::from_wire(status.as_str()), status);
1201 }
1202 }
1203
1204 #[test]
1205 fn job_feedback_from_event_rejects_wrong_kind() {
1206 let event = EventBuilder::text_note("not feedback")
1207 .sign_with_keys(&keys())
1208 .unwrap();
1209 assert!(matches!(
1210 JobFeedback::from_event(&event),
1211 Err(Nip90Error::InvalidFeedbackKind(_)),
1212 ));
1213 }
1214
1215 #[test]
1216 fn job_feedback_amount_without_invoice_round_trips() {
1217 let feedback = JobFeedback::new(FeedbackStatus::Processing).amount(Amount::new(1_000));
1218 let event = EventBuilder::dvm_job_feedback(&feedback)
1219 .sign_with_keys(&keys())
1220 .unwrap();
1221 let recovered = JobFeedback::from_event(&event).unwrap();
1222 let amount = recovered.amount.expect("Amount must round-trip");
1223 assert_eq!(amount.msats, 1_000);
1224 assert!(
1225 amount.bolt11.is_none(),
1226 "no invoice should round-trip as None"
1227 );
1228 }
1229}