#![allow(
clippy::excessive_nesting,
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"
)]
use thiserror::Error;
use crate::event::{
Alphabet, Event, EventBuilder, EventBuilderError, EventId, EventIdError, Kind, SingleLetterTag,
Tag, TagError, TagKind,
};
use crate::key::{PublicKey, PublicKeyError};
use crate::types::{RelayUrl, RelayUrlError};
pub const KIND_JOB_FEEDBACK: Kind = Kind::new(7_000);
pub const JOB_REQUEST_RANGE_START: u16 = 5_000;
pub const JOB_REQUEST_RANGE_END: u16 = 5_999;
pub const JOB_RESULT_RANGE_START: u16 = 6_000;
pub const JOB_RESULT_RANGE_END: u16 = 6_999;
pub const REQUEST_TO_RESULT_OFFSET: u16 = 1_000;
mod tag_names {
pub(super) const I: &str = "i";
pub(super) const OUTPUT: &str = "output";
pub(super) const PARAM: &str = "param";
pub(super) const BID: &str = "bid";
pub(super) const RELAYS: &str = "relays";
pub(super) const T: &str = "t";
pub(super) const REQUEST: &str = "request";
pub(super) const AMOUNT: &str = "amount";
pub(super) const STATUS: &str = "status";
pub(super) const ENCRYPTED: &str = "encrypted";
}
mod input_kinds {
pub(super) const URL: &str = "url";
pub(super) const EVENT: &str = "event";
pub(super) const JOB: &str = "job";
pub(super) const TEXT: &str = "text";
}
mod feedback_strings {
pub(super) const PAYMENT_REQUIRED: &str = "payment-required";
pub(super) const PROCESSING: &str = "processing";
pub(super) const ERROR: &str = "error";
pub(super) const SUCCESS: &str = "success";
pub(super) const PARTIAL: &str = "partial";
}
#[must_use]
pub const fn is_job_request_kind(kind: Kind) -> bool {
matches!(
kind.as_u16(),
JOB_REQUEST_RANGE_START..=JOB_REQUEST_RANGE_END
)
}
#[must_use]
pub const fn is_job_result_kind(kind: Kind) -> bool {
matches!(kind.as_u16(), JOB_RESULT_RANGE_START..=JOB_RESULT_RANGE_END)
}
#[must_use]
pub const fn result_kind_for(request_kind: Kind) -> Option<Kind> {
if is_job_request_kind(request_kind) {
Some(Kind::new(request_kind.as_u16() + REQUEST_TO_RESULT_OFFSET))
} else {
None
}
}
#[must_use]
pub const fn request_kind_for(result_kind: Kind) -> Option<Kind> {
if is_job_result_kind(result_kind) {
Some(Kind::new(result_kind.as_u16() - REQUEST_TO_RESULT_OFFSET))
} else {
None
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip90Error {
#[error("DVM job-request kind {0} is outside `5000..=5999`")]
InvalidRequestKind(Kind),
#[error("DVM job-result kind {0} is outside `6000..=6999`")]
InvalidResultKind(Kind),
#[error("expected kind 7000, got {0}")]
InvalidFeedbackKind(Kind),
#[error("result kind {got} does not match request kind {request} + 1000 = {expected}")]
KindMismatch {
request: Kind,
expected: Kind,
got: Kind,
},
#[error("DVM `i` tag has unknown marker `{0}` (expected url/event/job/text)")]
UnknownInputKind(String),
#[error("DVM millisat value `{0}` is not a valid u64")]
MalformedMillisats(String),
#[error("DVM `param` tag missing value column")]
MalformedParam,
#[error(transparent)]
PublicKey(#[from] PublicKeyError),
#[error(transparent)]
RelayUrl(#[from] RelayUrlError),
#[error(transparent)]
EventId(#[from] EventIdError),
#[error(transparent)]
Tag(#[from] TagError),
#[error(transparent)]
Builder(#[from] EventBuilderError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum JobInput {
Url(String),
Event {
event_id: EventId,
relay: Option<RelayUrl>,
},
Job {
event_id: EventId,
relay: Option<RelayUrl>,
},
Text(String),
}
impl JobInput {
fn render(&self, marker: Option<&str>) -> Vec<String> {
let mut row: Vec<String> = match self {
Self::Url(url) => vec![url.clone(), input_kinds::URL.to_owned(), String::new()],
Self::Text(text) => vec![text.clone(), input_kinds::TEXT.to_owned(), String::new()],
Self::Event { event_id, relay } => vec![
event_id.to_hex(),
input_kinds::EVENT.to_owned(),
relay
.as_ref()
.map(|r| r.as_str().to_owned())
.unwrap_or_default(),
],
Self::Job { event_id, relay } => vec![
event_id.to_hex(),
input_kinds::JOB.to_owned(),
relay
.as_ref()
.map(|r| r.as_str().to_owned())
.unwrap_or_default(),
],
};
if let Some(marker) = marker {
row.push(marker.to_owned());
}
row
}
fn parse(args: &[String]) -> Result<(Self, Option<String>), Nip90Error> {
let value = args.first().cloned().unwrap_or_default();
let kind = args
.get(1)
.cloned()
.unwrap_or_else(|| input_kinds::URL.to_owned());
let relay = args.get(2).and_then(|s| {
if s.is_empty() {
None
} else {
Some(RelayUrl::parse(s))
}
});
let marker = args.get(3).cloned();
let input = match kind.as_str() {
input_kinds::URL => Self::Url(value),
input_kinds::TEXT => Self::Text(value),
input_kinds::EVENT => Self::Event {
event_id: EventId::parse(&value)?,
relay: relay.transpose()?,
},
input_kinds::JOB => Self::Job {
event_id: EventId::parse(&value)?,
relay: relay.transpose()?,
},
other => return Err(Nip90Error::UnknownInputKind(other.to_owned())),
};
Ok((input, marker))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobInputRef {
pub input: JobInput,
pub marker: Option<String>,
}
impl JobInputRef {
#[must_use]
pub const fn new(input: JobInput) -> Self {
Self {
input,
marker: None,
}
}
#[must_use]
pub fn marker(mut self, marker: impl Into<String>) -> Self {
self.marker = Some(marker.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobParam {
pub key: String,
pub value: String,
}
impl JobParam {
#[must_use]
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Amount {
pub msats: u64,
pub bolt11: Option<String>,
}
impl Amount {
#[must_use]
pub const fn new(msats: u64) -> Self {
Self {
msats,
bolt11: None,
}
}
#[must_use]
pub fn invoice(mut self, bolt11: impl Into<String>) -> Self {
self.bolt11 = Some(bolt11.into());
self
}
fn render(&self) -> Vec<String> {
let mut row = vec![self.msats.to_string()];
if let Some(invoice) = &self.bolt11 {
row.push(invoice.clone());
}
row
}
fn parse(args: &[String]) -> Result<Self, Nip90Error> {
let raw = args
.first()
.ok_or_else(|| Nip90Error::MalformedMillisats(String::new()))?;
let msats: u64 = raw
.parse()
.map_err(|_| Nip90Error::MalformedMillisats(raw.clone()))?;
let bolt11 = args.get(1).cloned();
Ok(Self { msats, bolt11 })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobRequest {
pub kind: Kind,
pub content: String,
pub inputs: Vec<JobInputRef>,
pub output: Option<String>,
pub params: Vec<JobParam>,
pub bid_msats: Option<u64>,
pub relays: Vec<RelayUrl>,
pub topics: Vec<String>,
pub providers: Vec<PublicKey>,
pub encrypted: bool,
}
impl JobRequest {
pub const fn new(kind: Kind) -> Result<Self, Nip90Error> {
if !is_job_request_kind(kind) {
return Err(Nip90Error::InvalidRequestKind(kind));
}
Ok(Self {
kind,
content: String::new(),
inputs: Vec::new(),
output: None,
params: Vec::new(),
bid_msats: None,
relays: Vec::new(),
topics: Vec::new(),
providers: Vec::new(),
encrypted: false,
})
}
#[must_use]
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
#[must_use]
pub fn input(mut self, input: JobInputRef) -> Self {
self.inputs.push(input);
self
}
#[must_use]
pub fn param(mut self, param: JobParam) -> Self {
self.params.push(param);
self
}
#[must_use]
pub fn output(mut self, output: impl Into<String>) -> Self {
self.output = Some(output.into());
self
}
#[must_use]
pub const fn bid_msats(mut self, msats: u64) -> Self {
self.bid_msats = Some(msats);
self
}
#[must_use]
pub fn relay(mut self, url: RelayUrl) -> Self {
self.relays.push(url);
self
}
#[must_use]
pub fn topic(mut self, topic: impl Into<String>) -> Self {
self.topics.push(topic.into());
self
}
#[must_use]
pub fn provider(mut self, provider: PublicKey) -> Self {
self.providers.push(provider);
self
}
#[must_use]
pub const fn encrypted(mut self, encrypted: bool) -> Self {
self.encrypted = encrypted;
self
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
for input in &self.inputs {
tags.push(Tag::with(
&TagKind::custom(tag_names::I),
input.input.render(input.marker.as_deref()),
));
}
if let Some(output) = &self.output {
tags.push(Tag::with(
&TagKind::custom(tag_names::OUTPUT),
[output.clone()],
));
}
for param in &self.params {
tags.push(Tag::with(
&TagKind::custom(tag_names::PARAM),
[param.key.clone(), param.value.clone()],
));
}
if let Some(bid) = self.bid_msats {
tags.push(Tag::with(
&TagKind::custom(tag_names::BID),
[bid.to_string()],
));
}
if !self.relays.is_empty() {
let mut row = vec![tag_names::RELAYS.to_owned()];
for relay in &self.relays {
row.push(relay.as_str().to_owned());
}
row.remove(0);
tags.push(Tag::with(&TagKind::custom(tag_names::RELAYS), row));
}
for topic in &self.topics {
tags.push(Tag::with(&TagKind::custom(tag_names::T), [topic.clone()]));
}
for provider in &self.providers {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
[provider.to_hex()],
));
}
if self.encrypted {
tags.push(Tag::with(
&TagKind::custom(tag_names::ENCRYPTED),
Vec::<String>::new(),
));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip90Error> {
if !is_job_request_kind(event.kind) {
return Err(Nip90Error::InvalidRequestKind(event.kind));
}
let mut req = Self::new(event.kind)?;
req.content.clone_from(&event.content);
for tag in &event.tags {
let values = tag.values();
let args = values.get(1..).unwrap_or(&[]);
match tag.name() {
tag_names::I => {
let (input, marker) = JobInput::parse(args)?;
req.inputs.push(JobInputRef { input, marker });
}
tag_names::OUTPUT => {
if let Some(value) = args.first() {
req.output = Some(value.clone());
}
}
tag_names::PARAM => {
let key = args.first().cloned().ok_or(Nip90Error::MalformedParam)?;
let value = args.get(1).cloned().ok_or(Nip90Error::MalformedParam)?;
req.params.push(JobParam { key, value });
}
tag_names::BID => {
if let Some(value) = args.first() {
let bid: u64 = value
.parse()
.map_err(|_| Nip90Error::MalformedMillisats(value.clone()))?;
req.bid_msats = Some(bid);
}
}
tag_names::RELAYS => {
for raw in args {
req.relays.push(RelayUrl::parse(raw)?);
}
}
tag_names::T => {
if let Some(value) = args.first() {
req.topics.push(value.clone());
}
}
"p" => {
if let Some(value) = args.first() {
req.providers.push(PublicKey::parse(value)?);
}
}
tag_names::ENCRYPTED => req.encrypted = true,
_ => {}
}
}
Ok(req)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobResult {
pub kind: Kind,
pub content: String,
pub request_json: Option<String>,
pub request_event: Option<EventId>,
pub request_relay: Option<RelayUrl>,
pub customer: Option<PublicKey>,
pub inputs: Vec<JobInputRef>,
pub amount: Option<Amount>,
pub encrypted: bool,
}
impl JobResult {
pub const fn new(kind: Kind) -> Result<Self, Nip90Error> {
if !is_job_result_kind(kind) {
return Err(Nip90Error::InvalidResultKind(kind));
}
Ok(Self {
kind,
content: String::new(),
request_json: None,
request_event: None,
request_relay: None,
customer: None,
inputs: Vec::new(),
amount: None,
encrypted: false,
})
}
#[must_use]
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
#[must_use]
pub fn request_json(mut self, json: impl Into<String>) -> Self {
self.request_json = Some(json.into());
self
}
#[must_use]
pub fn request_event(mut self, event: EventId, relay: Option<RelayUrl>) -> Self {
self.request_event = Some(event);
self.request_relay = relay;
self
}
#[must_use]
pub const fn customer(mut self, customer: PublicKey) -> Self {
self.customer = Some(customer);
self
}
#[must_use]
pub fn input(mut self, input: JobInputRef) -> Self {
self.inputs.push(input);
self
}
#[must_use]
pub fn amount(mut self, amount: Amount) -> Self {
self.amount = Some(amount);
self
}
#[must_use]
pub const fn encrypted(mut self, encrypted: bool) -> Self {
self.encrypted = encrypted;
self
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
if let Some(json) = &self.request_json {
tags.push(Tag::with(
&TagKind::custom(tag_names::REQUEST),
[json.clone()],
));
}
if let Some(event_id) = self.request_event {
let mut row = vec![event_id.to_hex()];
if let Some(relay) = &self.request_relay {
row.push(relay.as_str().to_owned());
}
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
row,
));
}
for input in &self.inputs {
tags.push(Tag::with(
&TagKind::custom(tag_names::I),
input.input.render(input.marker.as_deref()),
));
}
if let Some(customer) = self.customer {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
[customer.to_hex()],
));
}
if let Some(amount) = &self.amount {
tags.push(Tag::with(
&TagKind::custom(tag_names::AMOUNT),
amount.render(),
));
}
if self.encrypted {
tags.push(Tag::with(
&TagKind::custom(tag_names::ENCRYPTED),
Vec::<String>::new(),
));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip90Error> {
if !is_job_result_kind(event.kind) {
return Err(Nip90Error::InvalidResultKind(event.kind));
}
let mut result = Self::new(event.kind)?;
result.content.clone_from(&event.content);
for tag in &event.tags {
let values = tag.values();
let args = values.get(1..).unwrap_or(&[]);
match tag.name() {
tag_names::REQUEST => {
if let Some(json) = args.first() {
result.request_json = Some(json.clone());
}
}
"e" => {
if let Some(id_hex) = args.first() {
result.request_event = Some(EventId::parse(id_hex)?);
}
if let Some(relay) = args.get(1)
&& !relay.is_empty()
{
result.request_relay = Some(RelayUrl::parse(relay)?);
}
}
tag_names::I => {
let (input, marker) = JobInput::parse(args)?;
result.inputs.push(JobInputRef { input, marker });
}
"p" => {
if let Some(value) = args.first() {
result.customer = Some(PublicKey::parse(value)?);
}
}
tag_names::AMOUNT => {
result.amount = Some(Amount::parse(args)?);
}
tag_names::ENCRYPTED => result.encrypted = true,
_ => {}
}
}
Ok(result)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum FeedbackStatus {
PaymentRequired,
Processing,
Error,
Success,
Partial,
Custom(String),
}
impl FeedbackStatus {
#[must_use]
pub const fn as_str(&self) -> &str {
match self {
Self::PaymentRequired => feedback_strings::PAYMENT_REQUIRED,
Self::Processing => feedback_strings::PROCESSING,
Self::Error => feedback_strings::ERROR,
Self::Success => feedback_strings::SUCCESS,
Self::Partial => feedback_strings::PARTIAL,
Self::Custom(s) => s.as_str(),
}
}
#[must_use]
pub fn from_wire(s: &str) -> Self {
match s {
feedback_strings::PAYMENT_REQUIRED => Self::PaymentRequired,
feedback_strings::PROCESSING => Self::Processing,
feedback_strings::ERROR => Self::Error,
feedback_strings::SUCCESS => Self::Success,
feedback_strings::PARTIAL => Self::Partial,
other => Self::Custom(other.to_owned()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobFeedback {
pub content: String,
pub status: FeedbackStatus,
pub status_extra: Option<String>,
pub amount: Option<Amount>,
pub request_event: Option<EventId>,
pub request_relay: Option<RelayUrl>,
pub customer: Option<PublicKey>,
}
impl JobFeedback {
#[must_use]
pub const fn new(status: FeedbackStatus) -> Self {
Self {
content: String::new(),
status,
status_extra: None,
amount: None,
request_event: None,
request_relay: None,
customer: None,
}
}
#[must_use]
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
#[must_use]
pub fn status_extra(mut self, extra: impl Into<String>) -> Self {
self.status_extra = Some(extra.into());
self
}
#[must_use]
pub fn amount(mut self, amount: Amount) -> Self {
self.amount = Some(amount);
self
}
#[must_use]
pub fn request_event(mut self, event: EventId, relay: Option<RelayUrl>) -> Self {
self.request_event = Some(event);
self.request_relay = relay;
self
}
#[must_use]
pub const fn customer(mut self, customer: PublicKey) -> Self {
self.customer = Some(customer);
self
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
let mut status_row = vec![self.status.as_str().to_owned()];
if let Some(extra) = &self.status_extra {
status_row.push(extra.clone());
}
tags.push(Tag::with(&TagKind::custom(tag_names::STATUS), status_row));
if let Some(amount) = &self.amount {
tags.push(Tag::with(
&TagKind::custom(tag_names::AMOUNT),
amount.render(),
));
}
if let Some(event_id) = self.request_event {
let mut row = vec![event_id.to_hex()];
if let Some(relay) = &self.request_relay {
row.push(relay.as_str().to_owned());
}
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
row,
));
}
if let Some(customer) = self.customer {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
[customer.to_hex()],
));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip90Error> {
if event.kind != KIND_JOB_FEEDBACK {
return Err(Nip90Error::InvalidFeedbackKind(event.kind));
}
let mut feedback = Self::new(FeedbackStatus::Custom(String::new()));
feedback.content.clone_from(&event.content);
for tag in &event.tags {
let values = tag.values();
let args = values.get(1..).unwrap_or(&[]);
match tag.name() {
tag_names::STATUS => {
if let Some(value) = args.first() {
feedback.status = FeedbackStatus::from_wire(value);
}
feedback.status_extra = args.get(1).cloned();
}
tag_names::AMOUNT => {
feedback.amount = Some(Amount::parse(args)?);
}
"e" => {
if let Some(id_hex) = args.first() {
feedback.request_event = Some(EventId::parse(id_hex)?);
}
if let Some(relay) = args.get(1)
&& !relay.is_empty()
{
feedback.request_relay = Some(RelayUrl::parse(relay)?);
}
}
"p" => {
if let Some(value) = args.first() {
feedback.customer = Some(PublicKey::parse(value)?);
}
}
_ => {}
}
}
Ok(feedback)
}
}
impl EventBuilder {
#[must_use]
pub fn dvm_job_request(request: &JobRequest) -> Self {
let mut builder = Self::new(request.kind, request.content.clone());
for tag in request.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn dvm_job_result(result: &JobResult) -> Self {
let mut builder = Self::new(result.kind, result.content.clone());
for tag in result.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn dvm_job_feedback(feedback: &JobFeedback) -> Self {
let mut builder = Self::new(KIND_JOB_FEEDBACK, feedback.content.clone());
for tag in feedback.to_tags() {
builder = builder.tag(tag);
}
builder
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
fn other_keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
}
fn relay() -> RelayUrl {
RelayUrl::parse("wss://relay.example/").unwrap()
}
#[test]
fn kind_helpers_round_trip() {
let req = Kind::new(5_001);
let res = result_kind_for(req).unwrap();
assert_eq!(res, Kind::new(6_001));
assert_eq!(request_kind_for(res), Some(req));
assert!(is_job_request_kind(req));
assert!(is_job_result_kind(res));
assert!(!is_job_request_kind(res));
assert!(result_kind_for(Kind::TEXT_NOTE).is_none());
assert!(request_kind_for(Kind::new(7_000)).is_none());
}
#[test]
fn job_request_round_trips_through_event() {
let request = JobRequest::new(Kind::new(5_001))
.unwrap()
.input(JobInputRef::new(JobInput::Text("hello".to_owned())).marker("prompt"))
.input(JobInputRef::new(JobInput::Url(
"https://example.com/data".to_owned(),
)))
.output("text/plain")
.param(JobParam::new("model", "LLaMA-2"))
.param(JobParam::new("temperature", "0.5"))
.bid_msats(21_000)
.relay(relay())
.topic("bitcoin")
.provider(*other_keys().public_key());
let event = EventBuilder::dvm_job_request(&request)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, Kind::new(5_001));
let recovered = JobRequest::from_event(&event).unwrap();
assert_eq!(recovered, request);
}
#[test]
fn job_request_new_rejects_kind_outside_range() {
assert!(matches!(
JobRequest::new(Kind::TEXT_NOTE),
Err(Nip90Error::InvalidRequestKind(_)),
));
assert!(matches!(
JobRequest::new(Kind::new(6_000)),
Err(Nip90Error::InvalidRequestKind(_)),
));
}
#[test]
fn job_request_input_kinds_round_trip() {
let request = JobRequest::new(Kind::new(5_002))
.unwrap()
.input(JobInputRef::new(JobInput::Event {
event_id: EventId::from_byte_array([0xaa; 32]),
relay: Some(relay()),
}))
.input(JobInputRef::new(JobInput::Job {
event_id: EventId::from_byte_array([0xbb; 32]),
relay: None,
}))
.input(JobInputRef::new(JobInput::Text("hi".to_owned())));
let event = EventBuilder::dvm_job_request(&request)
.sign_with_keys(&keys())
.unwrap();
let recovered = JobRequest::from_event(&event).unwrap();
assert_eq!(recovered.inputs, request.inputs);
}
#[test]
fn job_request_encrypted_marker_round_trips() {
let request = JobRequest::new(Kind::new(5_050))
.unwrap()
.content("ciphertext")
.encrypted(true);
let event = EventBuilder::dvm_job_request(&request)
.sign_with_keys(&keys())
.unwrap();
let has_marker = event.tags.iter().any(|t| t.name() == "encrypted");
assert!(has_marker);
let recovered = JobRequest::from_event(&event).unwrap();
assert!(recovered.encrypted);
}
#[test]
fn job_result_round_trips_through_event() {
let result = JobResult::new(Kind::new(6_001))
.unwrap()
.content("translation output")
.request_json("{\"id\":\"abc\"}")
.request_event(EventId::from_byte_array([0x11; 32]), Some(relay()))
.customer(*keys().public_key())
.input(JobInputRef::new(JobInput::Url(
"https://example.com".to_owned(),
)))
.amount(Amount::new(10_000).invoice("lnbc1..."));
let event = EventBuilder::dvm_job_result(&result)
.sign_with_keys(&other_keys())
.unwrap();
assert_eq!(event.kind, Kind::new(6_001));
let recovered = JobResult::from_event(&event).unwrap();
assert_eq!(recovered, result);
}
#[test]
fn job_result_new_rejects_kind_outside_range() {
assert!(matches!(
JobResult::new(Kind::TEXT_NOTE),
Err(Nip90Error::InvalidResultKind(_)),
));
assert!(matches!(
JobResult::new(Kind::new(5_001)),
Err(Nip90Error::InvalidResultKind(_)),
));
}
#[test]
fn job_feedback_round_trips_through_event() {
let feedback = JobFeedback::new(FeedbackStatus::PaymentRequired)
.status_extra("Please pay 21 sats")
.amount(Amount::new(21_000).invoice("lnbc..."))
.request_event(EventId::from_byte_array([0x22; 32]), Some(relay()))
.customer(*keys().public_key())
.content("partial sample");
let event = EventBuilder::dvm_job_feedback(&feedback)
.sign_with_keys(&other_keys())
.unwrap();
assert_eq!(event.kind, KIND_JOB_FEEDBACK);
let recovered = JobFeedback::from_event(&event).unwrap();
assert_eq!(recovered, feedback);
}
#[test]
fn job_feedback_status_round_trips_through_wire_form() {
for status in [
FeedbackStatus::PaymentRequired,
FeedbackStatus::Processing,
FeedbackStatus::Error,
FeedbackStatus::Success,
FeedbackStatus::Partial,
FeedbackStatus::Custom("queued".to_owned()),
] {
assert_eq!(FeedbackStatus::from_wire(status.as_str()), status);
}
}
#[test]
fn job_feedback_from_event_rejects_wrong_kind() {
let event = EventBuilder::text_note("not feedback")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
JobFeedback::from_event(&event),
Err(Nip90Error::InvalidFeedbackKind(_)),
));
}
#[test]
fn job_feedback_amount_without_invoice_round_trips() {
let feedback = JobFeedback::new(FeedbackStatus::Processing).amount(Amount::new(1_000));
let event = EventBuilder::dvm_job_feedback(&feedback)
.sign_with_keys(&keys())
.unwrap();
let recovered = JobFeedback::from_event(&event).unwrap();
let amount = recovered.amount.expect("Amount must round-trip");
assert_eq!(amount.msats, 1_000);
assert!(
amount.bolt11.is_none(),
"no invoice should round-trip as None"
);
}
}