Skip to main content

nula_core/nips/
nip90.rs

1//! [NIP-90] Data Vending Machine — typed event bundles for the
2//! customer / service-provider interaction.
3//!
4//! NIP-90 reserves the kind range `5000..=7000` for the data vending
5//! machine (DVM) marketplace:
6//!
7//! | Range       | Role                                              |
8//! |-------------|---------------------------------------------------|
9//! | `5000-5999` | [`JobRequest`] — customer asks for compute        |
10//! | `6000-6999` | [`JobResult`] — service provider returns output   |
11//! | `7000`      | [`JobFeedback`] — service provider status update  |
12//!
13//! Result kind is always `1000` higher than the request kind: a
14//! `kind:5001` translation request gets a `kind:6001` translation
15//! result. The mapping is enforced by [`result_kind_for`] /
16//! [`request_kind_for`] / [`is_job_request_kind`] /
17//! [`is_job_result_kind`].
18//!
19//! # Why a typed module
20//!
21//! Upstream `rust-nostr` ships nothing for NIP-90. We model:
22//!
23//! 1. [`JobInput`] — typed enum for the four `i`-tag input kinds
24//!    (`url` / `event` / `job` / `text`) with optional relay hint
25//!    and downstream marker.
26//! 2. [`JobParam`] — key/value parameter rows.
27//! 3. [`JobRequest`] / [`JobResult`] / [`JobFeedback`] — the three
28//!    typed bundles, each with a `to_event` / `from_event` round
29//!    trip.
30//! 4. [`Amount`] — millisat payment hint with an optional bolt11
31//!    invoice.
32//! 5. [`FeedbackStatus`] — typed kind-7000 status enum with the
33//!    five spec values plus a `Custom(String)` escape hatch.
34//!
35//! The module is intentionally agnostic about encryption: when
36//! callers want to keep `i` / `param` rows secret per spec
37//! §"Encrypted Params", they encrypt the payload with NIP-04 (or
38//! NIP-44) themselves, stash the ciphertext in `.content`, and tag
39//! the event with `Tag::custom("encrypted")`.
40//! The typed bundles never auto-encrypt or decrypt so they stay
41//! usable from `--no-default-features` builds.
42//!
43//! [NIP-90]: https://github.com/nostr-protocol/nips/blob/master/90.md
44
45#![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
59/// `kind: 7000` — job feedback event.
60pub const KIND_JOB_FEEDBACK: Kind = Kind::new(7_000);
61/// Lower bound of the job-request kind range.
62pub const JOB_REQUEST_RANGE_START: u16 = 5_000;
63/// Upper bound (inclusive) of the job-request kind range.
64pub const JOB_REQUEST_RANGE_END: u16 = 5_999;
65/// Lower bound of the job-result kind range.
66pub const JOB_RESULT_RANGE_START: u16 = 6_000;
67/// Upper bound (inclusive) of the job-result kind range.
68pub const JOB_RESULT_RANGE_END: u16 = 6_999;
69/// Offset spec mandates between a request kind and its result kind.
70pub 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/// True when `kind` is in the reserved DVM job-request range
101/// `5000..=5999`.
102#[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/// True when `kind` is in the reserved DVM job-result range
111/// `6000..=6999`.
112#[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/// Map a job-request kind to its corresponding result kind
118/// (`request + 1000`). Returns `None` when `kind` is outside the
119/// `5000..=5999` range.
120#[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/// Map a job-result kind back to its corresponding request kind
130/// (`result - 1000`). Returns `None` when `kind` is outside the
131/// `6000..=6999` range.
132#[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/// Errors raised by the NIP-90 typed bundles.
142#[derive(Debug, Error)]
143#[non_exhaustive]
144pub enum Nip90Error {
145    /// A request kind was outside the `5000..=5999` range.
146    #[error("DVM job-request kind {0} is outside `5000..=5999`")]
147    InvalidRequestKind(Kind),
148    /// A result kind was outside the `6000..=6999` range.
149    #[error("DVM job-result kind {0} is outside `6000..=6999`")]
150    InvalidResultKind(Kind),
151    /// A feedback event was not `kind:7000`.
152    #[error("expected kind 7000, got {0}")]
153    InvalidFeedbackKind(Kind),
154    /// Result kind did not match `request_kind + 1000`.
155    #[error("result kind {got} does not match request kind {request} + 1000 = {expected}")]
156    KindMismatch {
157        /// Request kind sourced from the surrounding context.
158        request: Kind,
159        /// Expected result kind (`request + 1000`).
160        expected: Kind,
161        /// Actual result kind on the event.
162        got: Kind,
163    },
164    /// An `i` tag's marker was not one of `url` / `event` / `job` /
165    /// `text`.
166    #[error("DVM `i` tag has unknown marker `{0}` (expected url/event/job/text)")]
167    UnknownInputKind(String),
168    /// A bid / amount value was not a valid `u64`.
169    #[error("DVM millisat value `{0}` is not a valid u64")]
170    MalformedMillisats(String),
171    /// A `param` tag had no value column.
172    #[error("DVM `param` tag missing value column")]
173    MalformedParam,
174    /// A target / customer / provider pubkey was malformed.
175    #[error(transparent)]
176    PublicKey(#[from] PublicKeyError),
177    /// A relay URL was malformed.
178    #[error(transparent)]
179    RelayUrl(#[from] RelayUrlError),
180    /// An event id was malformed.
181    #[error(transparent)]
182    EventId(#[from] EventIdError),
183    /// A typed [`Tag`] could not be constructed.
184    #[error(transparent)]
185    Tag(#[from] TagError),
186    /// [`EventBuilder`] signing failed.
187    #[error(transparent)]
188    Builder(#[from] EventBuilderError),
189}
190
191/// Input column of an `i` tag.
192///
193/// Each row's spec layout is `[head, value, kind, relay?, marker?]`
194/// where `head == "i"`. The four kinds match the spec's
195/// `url`/`event`/`job`/`text` markers.
196#[derive(Debug, Clone, PartialEq, Eq)]
197#[non_exhaustive]
198pub enum JobInput {
199    /// `url` — fetch the data at this URL.
200    Url(String),
201    /// `event` — process the referenced Nostr event.
202    Event {
203        /// Event id of the referenced event.
204        event_id: EventId,
205        /// Optional relay hint where the event was published.
206        relay: Option<RelayUrl>,
207    },
208    /// `job` — chain on top of a previous job's output.
209    Job {
210        /// Event id of the previous job.
211        event_id: EventId,
212        /// Optional relay hint.
213        relay: Option<RelayUrl>,
214    },
215    /// `text` — inline text payload.
216    Text(String),
217}
218
219impl JobInput {
220    /// Encode the typed input as the NIP-90 `i` tag row (excluding
221    /// the `marker`, which is carried by the surrounding
222    /// [`JobInputRef`]).
223    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    /// Decode an `i` tag's argument list (without the head) into a
251    /// typed input plus its optional marker.
252    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/// One `i` tag row plus its optional `marker` column.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct JobInputRef {
286    /// The typed input.
287    pub input: JobInput,
288    /// Optional marker column (free-form per spec).
289    pub marker: Option<String>,
290}
291
292impl JobInputRef {
293    /// Construct an input row with no marker.
294    #[must_use]
295    pub const fn new(input: JobInput) -> Self {
296        Self {
297            input,
298            marker: None,
299        }
300    }
301
302    /// Set the marker column.
303    #[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/// Key/value `param` row.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct JobParam {
313    /// Parameter name.
314    pub key: String,
315    /// Parameter value.
316    pub value: String,
317}
318
319impl JobParam {
320    /// Construct a parameter row.
321    #[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/// Optional payment hint carried by a [`JobResult`] or
331/// [`JobFeedback`].
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct Amount {
334    /// Requested payment in millisats.
335    pub msats: u64,
336    /// Optional pre-built bolt11 invoice the customer can pay.
337    pub bolt11: Option<String>,
338}
339
340impl Amount {
341    /// Construct an amount with no invoice.
342    #[must_use]
343    pub const fn new(msats: u64) -> Self {
344        Self {
345            msats,
346            bolt11: None,
347        }
348    }
349
350    /// Attach a bolt11 invoice.
351    #[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/// Typed bundle for a `kind: 5000..=5999` job request.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct JobRequest {
380    /// Kind of the request (MUST live in `5000..=5999`).
381    pub kind: Kind,
382    /// `.content` — usually empty; spec allows free-form text or
383    /// the encrypted-params ciphertext when paired with the
384    /// `encrypted` tag.
385    pub content: String,
386    /// Input rows (zero or more).
387    pub inputs: Vec<JobInputRef>,
388    /// Expected output media type / format.
389    pub output: Option<String>,
390    /// Optional parameters (key/value).
391    pub params: Vec<JobParam>,
392    /// Optional max bid in millisats.
393    pub bid_msats: Option<u64>,
394    /// Relays where service providers SHOULD publish responses.
395    pub relays: Vec<RelayUrl>,
396    /// Hashtags scoping the request (`t` tags).
397    pub topics: Vec<String>,
398    /// Service providers the customer wants to reach (`p` tags).
399    pub providers: Vec<PublicKey>,
400    /// True when the inputs / params have been encrypted into
401    /// [`Self::content`]; controls the `encrypted` marker tag.
402    pub encrypted: bool,
403}
404
405impl JobRequest {
406    /// Construct a job request bound to `kind`.
407    ///
408    /// # Errors
409    ///
410    /// Returns [`Nip90Error::InvalidRequestKind`] when `kind` is
411    /// outside `5000..=5999`.
412    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    /// Set [`Self::content`].
431    #[must_use]
432    pub fn content(mut self, content: impl Into<String>) -> Self {
433        self.content = content.into();
434        self
435    }
436
437    /// Append an input row.
438    #[must_use]
439    pub fn input(mut self, input: JobInputRef) -> Self {
440        self.inputs.push(input);
441        self
442    }
443
444    /// Append a parameter row.
445    #[must_use]
446    pub fn param(mut self, param: JobParam) -> Self {
447        self.params.push(param);
448        self
449    }
450
451    /// Set [`Self::output`].
452    #[must_use]
453    pub fn output(mut self, output: impl Into<String>) -> Self {
454        self.output = Some(output.into());
455        self
456    }
457
458    /// Set [`Self::bid_msats`].
459    #[must_use]
460    pub const fn bid_msats(mut self, msats: u64) -> Self {
461        self.bid_msats = Some(msats);
462        self
463    }
464
465    /// Append a relay hint.
466    #[must_use]
467    pub fn relay(mut self, url: RelayUrl) -> Self {
468        self.relays.push(url);
469        self
470    }
471
472    /// Append a hashtag.
473    #[must_use]
474    pub fn topic(mut self, topic: impl Into<String>) -> Self {
475        self.topics.push(topic.into());
476        self
477    }
478
479    /// Append a target service-provider pubkey.
480    #[must_use]
481    pub fn provider(mut self, provider: PublicKey) -> Self {
482        self.providers.push(provider);
483        self
484    }
485
486    /// Mark inputs / params as encrypted (stamps the `encrypted`
487    /// marker tag on the rendered event).
488    #[must_use]
489    pub const fn encrypted(mut self, encrypted: bool) -> Self {
490        self.encrypted = encrypted;
491        self
492    }
493
494    /// Render the typed bundle to the public tag list.
495    #[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            // `Tag::with` takes the head separately; build the row
528            // without re-prepending the head.
529            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    /// Parse a signed job-request event.
551    ///
552    /// # Errors
553    ///
554    /// Returns [`Nip90Error::InvalidRequestKind`] when the event's
555    /// kind is outside `5000..=5999`; otherwise forwards every
556    /// per-tag parse error.
557    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/// Typed bundle for a `kind: 6000..=6999` job result.
613#[derive(Debug, Clone, PartialEq, Eq)]
614pub struct JobResult {
615    /// Result kind (MUST live in `6000..=6999`).
616    pub kind: Kind,
617    /// `.content` — typically the job output; ciphertext when
618    /// [`Self::encrypted`] is set.
619    pub content: String,
620    /// Stringified JSON of the original [`JobRequest`] event (the
621    /// `request` tag).
622    pub request_json: Option<String>,
623    /// Original request event id (the `e` tag).
624    pub request_event: Option<EventId>,
625    /// Optional relay hint paired with [`Self::request_event`].
626    pub request_relay: Option<RelayUrl>,
627    /// Customer pubkey (the `p` tag).
628    pub customer: Option<PublicKey>,
629    /// Original input(s) repeated for traceability.
630    pub inputs: Vec<JobInputRef>,
631    /// Optional payment hint.
632    pub amount: Option<Amount>,
633    /// True when [`Self::content`] is encrypted ciphertext.
634    pub encrypted: bool,
635}
636
637impl JobResult {
638    /// Construct a result bundle bound to `kind`.
639    ///
640    /// # Errors
641    ///
642    /// Returns [`Nip90Error::InvalidResultKind`] when `kind` is
643    /// outside `6000..=6999`.
644    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    /// Set [`Self::content`].
662    #[must_use]
663    pub fn content(mut self, content: impl Into<String>) -> Self {
664        self.content = content.into();
665        self
666    }
667
668    /// Set the `request` tag JSON.
669    #[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    /// Set the originating request `(event_id, relay?)`.
676    #[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    /// Set the customer pubkey.
684    #[must_use]
685    pub const fn customer(mut self, customer: PublicKey) -> Self {
686        self.customer = Some(customer);
687        self
688    }
689
690    /// Append an original input for traceability.
691    #[must_use]
692    pub fn input(mut self, input: JobInputRef) -> Self {
693        self.inputs.push(input);
694        self
695    }
696
697    /// Set the payment hint.
698    #[must_use]
699    pub fn amount(mut self, amount: Amount) -> Self {
700        self.amount = Some(amount);
701        self
702    }
703
704    /// Mark [`Self::content`] as encrypted ciphertext.
705    #[must_use]
706    pub const fn encrypted(mut self, encrypted: bool) -> Self {
707        self.encrypted = encrypted;
708        self
709    }
710
711    /// Render the typed bundle to the public tag list.
712    #[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    /// Parse a signed job-result event.
759    ///
760    /// # Errors
761    ///
762    /// Returns [`Nip90Error::InvalidResultKind`] when the event's
763    /// kind is outside `6000..=6999`; otherwise forwards every
764    /// per-tag parse error.
765    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/// Status column of a [`JobFeedback`] event.
811#[derive(Debug, Clone, PartialEq, Eq)]
812#[non_exhaustive]
813pub enum FeedbackStatus {
814    /// `payment-required`.
815    PaymentRequired,
816    /// `processing`.
817    Processing,
818    /// `error`.
819    Error,
820    /// `success`.
821    Success,
822    /// `partial` — partial result samples allowed in `.content`.
823    Partial,
824    /// Forward-compatible escape hatch for future status tokens.
825    Custom(String),
826}
827
828impl FeedbackStatus {
829    /// Wire-form string representation.
830    #[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    /// Parse a wire-form string. Unknown values fall through to
843    /// [`Self::Custom`].
844    #[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/// Typed bundle for a `kind: 7000` job-feedback event.
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct JobFeedback {
860    /// `.content` — usually empty or partial result samples.
861    pub content: String,
862    /// `status` tag.
863    pub status: FeedbackStatus,
864    /// Optional human-readable explanation paired with the status.
865    pub status_extra: Option<String>,
866    /// Optional payment hint.
867    pub amount: Option<Amount>,
868    /// Original request event id.
869    pub request_event: Option<EventId>,
870    /// Optional relay hint paired with [`Self::request_event`].
871    pub request_relay: Option<RelayUrl>,
872    /// Customer pubkey.
873    pub customer: Option<PublicKey>,
874}
875
876impl JobFeedback {
877    /// Construct a feedback bundle.
878    #[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    /// Set [`Self::content`].
892    #[must_use]
893    pub fn content(mut self, content: impl Into<String>) -> Self {
894        self.content = content.into();
895        self
896    }
897
898    /// Attach an extra human-readable status message.
899    #[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    /// Set the payment hint.
906    #[must_use]
907    pub fn amount(mut self, amount: Amount) -> Self {
908        self.amount = Some(amount);
909        self
910    }
911
912    /// Reference the originating request.
913    #[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    /// Set the customer pubkey.
921    #[must_use]
922    pub const fn customer(mut self, customer: PublicKey) -> Self {
923        self.customer = Some(customer);
924        self
925    }
926
927    /// Render the typed bundle to the public tag list.
928    #[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    /// Parse a signed `kind: 7000` event.
962    ///
963    /// # Errors
964    ///
965    /// Returns [`Nip90Error::InvalidFeedbackKind`] when the event's
966    /// kind is not `7000`; otherwise forwards every per-tag parse
967    /// error.
968    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    /// Author a NIP-90 job-request event from a typed [`JobRequest`].
1011    #[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    /// Author a NIP-90 job-result event from a typed [`JobResult`].
1021    #[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    /// Author a NIP-90 job-feedback event from a typed
1031    /// [`JobFeedback`].
1032    #[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}