1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6use serde::de::{MapAccess, Visitor};
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_json::Value;
9use url::Url;
10
11use crate::{
12 LocalIdentity, NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrTag,
13 SignedEvent, SignedNostrEvent, SoftchatError,
14};
15
16pub const MAX_RELAY_FRAME_BYTES: usize = 512 * 1024;
18pub const MAX_RELAY_BATCH_EVENTS: usize = 256;
20pub const MAX_SUBSCRIPTION_ID_CHARS: usize = 64;
22pub const MAX_RELAY_FILTERS: usize = 64;
24pub const MAX_RELAY_FILTER_VALUES: usize = 1_024;
26pub const MAX_RELAY_MESSAGE_CHARS: usize = 4_096;
28pub const NIP42_AUTH_WINDOW_SECONDS: u64 = 600;
30
31#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
33pub struct RelayFilter {
34 #[serde(skip_serializing_if = "Vec::is_empty")]
36 pub ids: Vec<String>,
37 #[serde(skip_serializing_if = "Vec::is_empty")]
39 pub authors: Vec<String>,
40 #[serde(skip_serializing_if = "Vec::is_empty")]
42 pub kinds: Vec<u16>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub since: Option<u64>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub until: Option<u64>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub limit: Option<u32>,
52 #[serde(flatten)]
54 pub generic_tags: BTreeMap<String, Vec<String>>,
55}
56
57impl RelayFilter {
58 pub fn from_json(json: &str) -> Result<Self, SoftchatError> {
64 if json.len() > MAX_RELAY_FRAME_BYTES {
65 return Err(SoftchatError::InvalidRelayFilter);
66 }
67 let mut deserializer = serde_json::Deserializer::from_str(json);
68 let filter =
69 Self::deserialize(&mut deserializer).map_err(|_| SoftchatError::InvalidRelayFilter)?;
70 deserializer
71 .end()
72 .map_err(|_| SoftchatError::InvalidRelayFilter)?;
73 filter.validate()
74 }
75
76 pub fn to_json(&self) -> Result<String, SoftchatError> {
82 self.clone().validate()?;
83 serde_json::to_string(self).map_err(|_| SoftchatError::InvalidRelayFilter)
84 }
85
86 fn validate(self) -> Result<Self, SoftchatError> {
87 validate_prefixes(&self.ids)?;
88 validate_prefixes(&self.authors)?;
89 validate_value_count(self.kinds.len())?;
90 if self
91 .since
92 .zip(self.until)
93 .is_some_and(|(since, until)| since > until)
94 {
95 return Err(SoftchatError::InvalidRelayFilter);
96 }
97 if self.limit == Some(0) {
98 return Err(SoftchatError::InvalidRelayFilter);
99 }
100 for (key, values) in &self.generic_tags {
101 if !is_generic_tag_key(key) || values.is_empty() {
102 return Err(SoftchatError::InvalidRelayFilter);
103 }
104 validate_value_count(values.len())?;
105 if values
106 .iter()
107 .any(|value| value.len() > MAX_RELAY_FRAME_BYTES)
108 {
109 return Err(SoftchatError::InvalidRelayFilter);
110 }
111 }
112 Ok(self)
113 }
114}
115
116impl<'de> Deserialize<'de> for RelayFilter {
117 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118 where
119 D: Deserializer<'de>,
120 {
121 struct FilterVisitor;
122
123 impl<'de> Visitor<'de> for FilterVisitor {
124 type Value = RelayFilter;
125
126 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 formatter.write_str("a bounded NIP-01 filter object")
128 }
129
130 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
131 where
132 A: MapAccess<'de>,
133 {
134 let mut filter = RelayFilter::default();
135 let mut seen = BTreeSet::new();
136 while let Some(key) = map.next_key::<String>()? {
137 if !seen.insert(key.clone()) {
138 return Err(serde::de::Error::custom("duplicate filter field"));
139 }
140 match key.as_str() {
141 "ids" => filter.ids = map.next_value()?,
142 "authors" => filter.authors = map.next_value()?,
143 "kinds" => filter.kinds = map.next_value()?,
144 "since" => filter.since = Some(map.next_value()?),
145 "until" => filter.until = Some(map.next_value()?),
146 "limit" => filter.limit = Some(map.next_value()?),
147 _ if is_generic_tag_key(&key) => {
148 filter.generic_tags.insert(key, map.next_value()?);
149 }
150 _ => return Err(serde::de::Error::custom("unknown filter field")),
151 }
152 }
153 filter.validate().map_err(serde::de::Error::custom)
154 }
155 }
156
157 deserializer.deserialize_map(FilterVisitor)
158 }
159}
160
161#[derive(Clone, Debug, Eq, PartialEq)]
163pub enum ClientRelayFrame {
164 Event(SignedNostrEvent),
166 Events(Vec<SignedNostrEvent>),
168 Req {
170 subscription_id: String,
172 filters: Vec<RelayFilter>,
174 },
175 Close {
177 subscription_id: String,
179 },
180 Count {
182 subscription_id: String,
184 filters: Vec<RelayFilter>,
186 },
187 Auth(SignedNostrEvent),
189}
190
191impl ClientRelayFrame {
192 pub fn to_json(&self) -> Result<String, SoftchatError> {
198 let mut values = Vec::new();
199 match self {
200 Self::Event(event) => {
201 values.push(Value::String("EVENT".to_owned()));
202 values.push(event_value(event)?);
203 }
204 Self::Events(events) => {
205 validate_batch_events(events)?;
206 values.push(Value::String("EVENTS".to_owned()));
207 for event in events {
208 values.push(event_value(event)?);
209 }
210 }
211 Self::Req {
212 subscription_id,
213 filters,
214 } => {
215 validate_subscription_id(subscription_id)?;
216 validate_filters(filters)?;
217 values.push(Value::String("REQ".to_owned()));
218 values.push(Value::String(subscription_id.clone()));
219 append_filter_values(&mut values, filters)?;
220 }
221 Self::Close { subscription_id } => {
222 validate_subscription_id(subscription_id)?;
223 values.push(Value::String("CLOSE".to_owned()));
224 values.push(Value::String(subscription_id.clone()));
225 }
226 Self::Count {
227 subscription_id,
228 filters,
229 } => {
230 validate_subscription_id(subscription_id)?;
231 validate_filters(filters)?;
232 values.push(Value::String("COUNT".to_owned()));
233 values.push(Value::String(subscription_id.clone()));
234 append_filter_values(&mut values, filters)?;
235 }
236 Self::Auth(event) => {
237 if event.kind() != NostrEventKind::CLIENT_AUTHENTICATION {
238 return Err(SoftchatError::InvalidRelayAuthentication);
239 }
240 values.push(Value::String("AUTH".to_owned()));
241 values.push(event_value(event)?);
242 }
243 }
244 encode_frame(values)
245 }
246}
247
248#[derive(Clone, Debug, Eq, PartialEq)]
250pub enum RelayResponseFrame {
251 Event {
253 subscription_id: String,
255 event: SignedNostrEvent,
257 },
258 Ok(BatchAcknowledgement),
260 Eose {
262 subscription_id: String,
264 },
265 Closed {
267 subscription_id: String,
269 message: String,
271 },
272 Notice(String),
274 Auth(String),
276 Count {
278 subscription_id: String,
280 count: u64,
282 },
283}
284
285impl RelayResponseFrame {
286 pub fn to_json(&self) -> Result<String, SoftchatError> {
292 let values = match self {
293 Self::Event {
294 subscription_id,
295 event,
296 } => {
297 validate_subscription_id(subscription_id)?;
298 vec![
299 Value::String("EVENT".to_owned()),
300 Value::String(subscription_id.clone()),
301 event_value(event)?,
302 ]
303 }
304 Self::Ok(acknowledgement) => {
305 acknowledgement.validate()?;
306 vec![
307 Value::String("OK".to_owned()),
308 Value::String(acknowledgement.event_id.clone()),
309 Value::Bool(acknowledgement.accepted),
310 Value::String(acknowledgement.message.clone()),
311 ]
312 }
313 Self::Eose { subscription_id } => {
314 validate_subscription_id(subscription_id)?;
315 vec![
316 Value::String("EOSE".to_owned()),
317 Value::String(subscription_id.clone()),
318 ]
319 }
320 Self::Closed {
321 subscription_id,
322 message,
323 } => {
324 validate_subscription_id(subscription_id)?;
325 validate_relay_text(message)?;
326 vec![
327 Value::String("CLOSED".to_owned()),
328 Value::String(subscription_id.clone()),
329 Value::String(message.clone()),
330 ]
331 }
332 Self::Notice(message) => {
333 validate_relay_text(message)?;
334 vec![
335 Value::String("NOTICE".to_owned()),
336 Value::String(message.clone()),
337 ]
338 }
339 Self::Auth(challenge) => {
340 validate_relay_text(challenge)?;
341 if challenge.is_empty() {
342 return Err(SoftchatError::InvalidRelayAuthentication);
343 }
344 vec![
345 Value::String("AUTH".to_owned()),
346 Value::String(challenge.clone()),
347 ]
348 }
349 Self::Count {
350 subscription_id,
351 count,
352 } => {
353 validate_subscription_id(subscription_id)?;
354 vec![
355 Value::String("COUNT".to_owned()),
356 Value::String(subscription_id.clone()),
357 serde_json::json!({ "count": count }),
358 ]
359 }
360 };
361 encode_frame(values)
362 }
363}
364
365#[derive(Clone, Debug, Eq, PartialEq)]
367#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
368pub struct BatchAcknowledgement {
369 pub event_id: String,
371 pub accepted: bool,
373 pub message: String,
375}
376
377impl BatchAcknowledgement {
378 fn validate(&self) -> Result<(), SoftchatError> {
379 NostrEventId::from_hex(&self.event_id).map_err(|_| SoftchatError::InvalidRelayBatch)?;
380 validate_relay_text(&self.message).map_err(|_| SoftchatError::InvalidRelayBatch)
381 }
382}
383
384#[derive(Clone, Copy, Debug, Eq, PartialEq)]
386#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
387pub enum BatchEventState {
388 Pending,
390 Accepted,
392 Rejected,
394}
395
396#[derive(Clone, Debug, Eq, PartialEq)]
398#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
399pub struct BatchProgress {
400 pub event_ids: Vec<String>,
402 pub states: Vec<BatchEventState>,
404 pub messages: Vec<String>,
406 pub complete: bool,
408}
409
410pub fn parse_client_relay_frame(json: &str) -> Result<ClientRelayFrame, SoftchatError> {
417 let values = parse_frame_array(json)?;
418 let command = command(&values)?;
419 match command {
420 "EVENT" if values.len() == 2 => Ok(ClientRelayFrame::Event(parse_event(&values[1])?)),
421 "EVENTS" if values.len() >= 2 => {
422 let events = values[1..]
423 .iter()
424 .map(parse_event)
425 .collect::<Result<Vec<_>, _>>()?;
426 validate_batch_events(&events)?;
427 Ok(ClientRelayFrame::Events(events))
428 }
429 "REQ" | "COUNT" if values.len() >= 3 => {
430 let subscription_id = parse_subscription_id(&values[1])?;
431 let filters = values[2..]
432 .iter()
433 .map(parse_filter_value)
434 .collect::<Result<Vec<_>, _>>()?;
435 validate_filters(&filters)?;
436 if command == "REQ" {
437 Ok(ClientRelayFrame::Req {
438 subscription_id,
439 filters,
440 })
441 } else {
442 Ok(ClientRelayFrame::Count {
443 subscription_id,
444 filters,
445 })
446 }
447 }
448 "CLOSE" if values.len() == 2 => Ok(ClientRelayFrame::Close {
449 subscription_id: parse_subscription_id(&values[1])?,
450 }),
451 "AUTH" if values.len() == 2 => {
452 let event = parse_event(&values[1])?;
453 if event.kind() != NostrEventKind::CLIENT_AUTHENTICATION {
454 return Err(SoftchatError::InvalidRelayAuthentication);
455 }
456 Ok(ClientRelayFrame::Auth(event))
457 }
458 _ => Err(SoftchatError::InvalidRelayFrame),
459 }
460}
461
462pub fn parse_relay_response_frame(json: &str) -> Result<RelayResponseFrame, SoftchatError> {
469 let values = parse_frame_array(json)?;
470 match command(&values)? {
471 "EVENT" if values.len() == 3 => Ok(RelayResponseFrame::Event {
472 subscription_id: parse_subscription_id(&values[1])?,
473 event: parse_event(&values[2])?,
474 }),
475 "OK" if values.len() == 4 => {
476 let acknowledgement = BatchAcknowledgement {
477 event_id: value_string(&values[1])?.to_owned(),
478 accepted: values[2]
479 .as_bool()
480 .ok_or(SoftchatError::InvalidRelayFrame)?,
481 message: value_string(&values[3])?.to_owned(),
482 };
483 acknowledgement
484 .validate()
485 .map_err(|_| SoftchatError::InvalidRelayFrame)?;
486 Ok(RelayResponseFrame::Ok(acknowledgement))
487 }
488 "EOSE" if values.len() == 2 => Ok(RelayResponseFrame::Eose {
489 subscription_id: parse_subscription_id(&values[1])?,
490 }),
491 "CLOSED" if values.len() == 3 => {
492 let message = value_string(&values[2])?.to_owned();
493 validate_relay_text(&message)?;
494 Ok(RelayResponseFrame::Closed {
495 subscription_id: parse_subscription_id(&values[1])?,
496 message,
497 })
498 }
499 "NOTICE" if values.len() == 2 => {
500 let message = value_string(&values[1])?.to_owned();
501 validate_relay_text(&message)?;
502 Ok(RelayResponseFrame::Notice(message))
503 }
504 "AUTH" if values.len() == 2 => {
505 let challenge = value_string(&values[1])?.to_owned();
506 validate_relay_text(&challenge)?;
507 if challenge.is_empty() {
508 return Err(SoftchatError::InvalidRelayAuthentication);
509 }
510 Ok(RelayResponseFrame::Auth(challenge))
511 }
512 "COUNT" if values.len() == 3 => {
513 let count = values[2]
514 .as_object()
515 .filter(|object| object.len() == 1)
516 .and_then(|object| object.get("count"))
517 .and_then(Value::as_u64)
518 .ok_or(SoftchatError::InvalidRelayFrame)?;
519 Ok(RelayResponseFrame::Count {
520 subscription_id: parse_subscription_id(&values[1])?,
521 count,
522 })
523 }
524 _ => Err(SoftchatError::InvalidRelayFrame),
525 }
526}
527
528#[cfg_attr(feature = "native-bindings", uniffi::export)]
535pub fn encode_events_frame(events: Vec<SignedEvent>) -> Result<String, SoftchatError> {
536 let events = events
537 .into_iter()
538 .map(SignedNostrEvent::try_from)
539 .collect::<Result<Vec<_>, _>>()
540 .map_err(|_| SoftchatError::InvalidRelayBatch)?;
541 ClientRelayFrame::Events(events)
542 .to_json()
543 .map_err(|_| SoftchatError::InvalidRelayBatch)
544}
545
546#[cfg_attr(feature = "native-bindings", uniffi::export)]
556pub fn track_batch_acknowledgements(
557 event_ids: Vec<String>,
558 acknowledgements: Vec<BatchAcknowledgement>,
559) -> Result<BatchProgress, SoftchatError> {
560 if event_ids.is_empty() || event_ids.len() > MAX_RELAY_BATCH_EVENTS {
561 return Err(SoftchatError::InvalidRelayBatch);
562 }
563
564 let mut indexes = BTreeMap::new();
565 for (index, event_id) in event_ids.iter().enumerate() {
566 NostrEventId::from_hex(event_id).map_err(|_| SoftchatError::InvalidRelayBatch)?;
567 if indexes.insert(event_id.clone(), index).is_some() {
568 return Err(SoftchatError::InvalidRelayBatch);
569 }
570 }
571
572 let mut states = vec![BatchEventState::Pending; event_ids.len()];
573 let mut messages = vec![String::new(); event_ids.len()];
574 for acknowledgement in acknowledgements {
575 acknowledgement.validate()?;
576 let index = *indexes
577 .get(&acknowledgement.event_id)
578 .ok_or(SoftchatError::InvalidRelayBatch)?;
579 let state = if acknowledgement.accepted {
580 BatchEventState::Accepted
581 } else {
582 BatchEventState::Rejected
583 };
584 match states[index] {
585 BatchEventState::Pending => {
586 states[index] = state;
587 messages[index] = acknowledgement.message;
588 }
589 current if current == state && messages[index] == acknowledgement.message => {}
590 _ => return Err(SoftchatError::InvalidRelayBatch),
591 }
592 }
593
594 let complete = states
595 .iter()
596 .all(|state| *state != BatchEventState::Pending);
597 Ok(BatchProgress {
598 event_ids,
599 states,
600 messages,
601 complete,
602 })
603}
604
605pub fn plan_nip42_authentication(
612 identity: &LocalIdentity,
613 challenge: &str,
614 relay_url: &str,
615 created_at: u64,
616) -> Result<SignedNostrEvent, SoftchatError> {
617 validate_challenge(challenge)?;
618 validate_relay_url(relay_url)?;
619 let tags = vec![
620 NostrTag::new(vec!["relay", relay_url])
621 .map_err(|_| SoftchatError::InvalidRelayAuthentication)?,
622 NostrTag::new(vec!["challenge", challenge])
623 .map_err(|_| SoftchatError::InvalidRelayAuthentication)?,
624 ];
625 let draft = NostrEventDraft::new(created_at, NostrEventKind::CLIENT_AUTHENTICATION, tags, "")
626 .map_err(|_| SoftchatError::InvalidRelayAuthentication)?;
627 identity
628 .sign_event(draft)
629 .map_err(|_| SoftchatError::InvalidRelayAuthentication)
630}
631
632pub fn validate_nip42_authentication(
639 event: &SignedNostrEvent,
640 expected_author: &NostrPublicKey,
641 challenge: &str,
642 relay_url: &str,
643 now: u64,
644) -> Result<(), SoftchatError> {
645 validate_challenge(challenge)?;
646 validate_relay_url(relay_url)?;
647 if event.kind() != NostrEventKind::CLIENT_AUTHENTICATION
648 || event.public_key() != *expected_author
649 || !event.content().is_empty()
650 || event.created_at().abs_diff(now) > NIP42_AUTH_WINDOW_SECONDS
651 {
652 return Err(SoftchatError::InvalidRelayAuthentication);
653 }
654
655 let mut found_relay = false;
656 let mut found_challenge = false;
657 for tag in event.tags() {
658 match tag.first().map(String::as_str) {
659 Some("relay") if !found_relay && tag.len() == 2 && tag[1] == relay_url => {
660 found_relay = true;
661 }
662 Some("challenge") if !found_challenge && tag.len() == 2 && tag[1] == challenge => {
663 found_challenge = true;
664 }
665 Some("relay" | "challenge") => {
666 return Err(SoftchatError::InvalidRelayAuthentication);
667 }
668 _ => {}
669 }
670 }
671 if found_relay && found_challenge {
672 Ok(())
673 } else {
674 Err(SoftchatError::InvalidRelayAuthentication)
675 }
676}
677
678fn parse_frame_array(json: &str) -> Result<Vec<Value>, SoftchatError> {
679 if json.is_empty() || json.len() > MAX_RELAY_FRAME_BYTES {
680 return Err(SoftchatError::InvalidRelayFrame);
681 }
682 let value: Value = serde_json::from_str(json).map_err(|_| SoftchatError::InvalidRelayFrame)?;
683 value
684 .as_array()
685 .filter(|values| !values.is_empty())
686 .cloned()
687 .ok_or(SoftchatError::InvalidRelayFrame)
688}
689
690fn command(values: &[Value]) -> Result<&str, SoftchatError> {
691 values
692 .first()
693 .and_then(Value::as_str)
694 .ok_or(SoftchatError::InvalidRelayFrame)
695}
696
697fn value_string(value: &Value) -> Result<&str, SoftchatError> {
698 value.as_str().ok_or(SoftchatError::InvalidRelayFrame)
699}
700
701fn parse_event(value: &Value) -> Result<SignedNostrEvent, SoftchatError> {
702 let json = serde_json::to_string(value).map_err(|_| SoftchatError::InvalidRelayFrame)?;
703 SignedNostrEvent::from_json(&json).map_err(|_| SoftchatError::InvalidRelayFrame)
704}
705
706fn event_value(event: &SignedNostrEvent) -> Result<Value, SoftchatError> {
707 serde_json::from_str(&event.to_json()?).map_err(|_| SoftchatError::InvalidRelayFrame)
708}
709
710fn parse_filter_value(value: &Value) -> Result<RelayFilter, SoftchatError> {
711 RelayFilter::from_json(
712 &serde_json::to_string(value).map_err(|_| SoftchatError::InvalidRelayFilter)?,
713 )
714}
715
716fn append_filter_values(
717 values: &mut Vec<Value>,
718 filters: &[RelayFilter],
719) -> Result<(), SoftchatError> {
720 for filter in filters {
721 values.push(
722 serde_json::to_value(filter.clone().validate()?)
723 .map_err(|_| SoftchatError::InvalidRelayFilter)?,
724 );
725 }
726 Ok(())
727}
728
729fn encode_frame(values: Vec<Value>) -> Result<String, SoftchatError> {
730 let json = serde_json::to_string(&values).map_err(|_| SoftchatError::InvalidRelayFrame)?;
731 if json.len() > MAX_RELAY_FRAME_BYTES {
732 return Err(SoftchatError::InvalidRelayFrame);
733 }
734 Ok(json)
735}
736
737fn parse_subscription_id(value: &Value) -> Result<String, SoftchatError> {
738 let subscription_id = value_string(value)?.to_owned();
739 validate_subscription_id(&subscription_id)?;
740 Ok(subscription_id)
741}
742
743fn validate_subscription_id(subscription_id: &str) -> Result<(), SoftchatError> {
744 let length = subscription_id.chars().count();
745 if length == 0 || length > MAX_SUBSCRIPTION_ID_CHARS {
746 return Err(SoftchatError::InvalidRelayFrame);
747 }
748 Ok(())
749}
750
751fn validate_relay_text(value: &str) -> Result<(), SoftchatError> {
752 if value.chars().count() > MAX_RELAY_MESSAGE_CHARS {
753 return Err(SoftchatError::InvalidRelayFrame);
754 }
755 Ok(())
756}
757
758fn validate_challenge(challenge: &str) -> Result<(), SoftchatError> {
759 validate_relay_text(challenge).map_err(|_| SoftchatError::InvalidRelayAuthentication)?;
760 if challenge.is_empty() {
761 return Err(SoftchatError::InvalidRelayAuthentication);
762 }
763 Ok(())
764}
765
766fn validate_relay_url(relay_url: &str) -> Result<(), SoftchatError> {
767 let url = Url::parse(relay_url).map_err(|_| SoftchatError::InvalidRelayAuthentication)?;
768 if !matches!(url.scheme(), "ws" | "wss") || url.host_str().is_none() || url.fragment().is_some()
769 {
770 return Err(SoftchatError::InvalidRelayAuthentication);
771 }
772 Ok(())
773}
774
775fn validate_prefixes(values: &[String]) -> Result<(), SoftchatError> {
776 validate_value_count(values.len())?;
777 for value in values {
778 if value.is_empty()
779 || value.len() > 64
780 || !value
781 .bytes()
782 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
783 {
784 return Err(SoftchatError::InvalidRelayFilter);
785 }
786 }
787 Ok(())
788}
789
790fn validate_value_count(count: usize) -> Result<(), SoftchatError> {
791 if count > MAX_RELAY_FILTER_VALUES {
792 return Err(SoftchatError::InvalidRelayFilter);
793 }
794 Ok(())
795}
796
797fn is_generic_tag_key(key: &str) -> bool {
798 let bytes = key.as_bytes();
799 bytes.len() == 2 && bytes[0] == b'#' && bytes[1].is_ascii_alphabetic()
800}
801
802fn validate_filters(filters: &[RelayFilter]) -> Result<(), SoftchatError> {
803 if filters.is_empty() || filters.len() > MAX_RELAY_FILTERS {
804 return Err(SoftchatError::InvalidRelayFrame);
805 }
806 for filter in filters {
807 filter
808 .clone()
809 .validate()
810 .map_err(|_| SoftchatError::InvalidRelayFrame)?;
811 }
812 Ok(())
813}
814
815fn validate_batch_events(events: &[SignedNostrEvent]) -> Result<(), SoftchatError> {
816 if events.is_empty() || events.len() > MAX_RELAY_BATCH_EVENTS {
817 return Err(SoftchatError::InvalidRelayBatch);
818 }
819 let mut ids = BTreeSet::new();
820 if events.iter().any(|event| !ids.insert(event.id())) {
821 return Err(SoftchatError::InvalidRelayBatch);
822 }
823 Ok(())
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829
830 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
831
832 fn event(kind: NostrEventKind, created_at: u64) -> Result<SignedNostrEvent, SoftchatError> {
833 let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
834 identity.sign_event(NostrEventDraft::new(created_at, kind, Vec::new(), "test")?)
835 }
836
837 #[test]
838 fn filters_round_trip_generic_tags_and_reject_duplicate_fields() -> Result<(), SoftchatError> {
839 let filter = RelayFilter::from_json(
840 r##"{"ids":["ab"],"authors":["cd"],"kinds":[14],"since":1,"until":2,"limit":3,"#p":["ef"]}"##,
841 )?;
842 assert_eq!(RelayFilter::from_json(&filter.to_json()?)?, filter);
843 assert!(RelayFilter::from_json(r#"{"limit":1,"limit":2}"#).is_err());
844 assert!(RelayFilter::from_json(r#"{"unknown":[]}"#).is_err());
845 assert!(RelayFilter::from_json(r#"{"since":2,"until":1}"#).is_err());
846 Ok(())
847 }
848
849 #[test]
850 fn client_and_relay_frames_round_trip_and_reject_trailing_items() -> Result<(), SoftchatError> {
851 let authored = event(NostrEventKind::SHORT_TEXT_NOTE, 1)?;
852 for frame in [
853 ClientRelayFrame::Event(authored.clone()),
854 ClientRelayFrame::Req {
855 subscription_id: "sub".to_owned(),
856 filters: vec![RelayFilter::default()],
857 },
858 ClientRelayFrame::Close {
859 subscription_id: "sub".to_owned(),
860 },
861 ClientRelayFrame::Count {
862 subscription_id: "sub".to_owned(),
863 filters: vec![RelayFilter::default()],
864 },
865 ] {
866 let encoded = frame.to_json()?;
867 assert_eq!(parse_client_relay_frame(&encoded)?, frame);
868 }
869
870 let response = RelayResponseFrame::Event {
871 subscription_id: "sub".to_owned(),
872 event: authored,
873 };
874 assert_eq!(parse_relay_response_frame(&response.to_json()?)?, response);
875 assert!(parse_relay_response_frame(r#"["EOSE","sub","trailing"]"#).is_err());
876 assert!(parse_client_relay_frame(r#"["UNKNOWN"]"#).is_err());
877 Ok(())
878 }
879
880 #[test]
881 fn events_batch_is_flat_non_empty_unique_and_bounded() -> Result<(), SoftchatError> {
882 let first = event(NostrEventKind::SHORT_TEXT_NOTE, 1)?;
883 let second = event(NostrEventKind::SHORT_TEXT_NOTE, 2)?;
884 let frame = ClientRelayFrame::Events(vec![first.clone(), second.clone()]);
885 let json = frame.to_json()?;
886 assert_eq!(parse_client_relay_frame(&json)?, frame);
887 assert!(json.starts_with("[\"EVENTS\",{"));
888 assert!(ClientRelayFrame::Events(Vec::new()).to_json().is_err());
889 assert!(
890 ClientRelayFrame::Events(vec![first.clone(), first])
891 .to_json()
892 .is_err()
893 );
894 Ok(())
895 }
896
897 #[test]
898 fn batch_tracker_is_per_event_idempotent_and_conflict_strict() -> Result<(), SoftchatError> {
899 let first = event(NostrEventKind::SHORT_TEXT_NOTE, 1)?.id().to_hex();
900 let second = event(NostrEventKind::SHORT_TEXT_NOTE, 2)?.id().to_hex();
901 let accepted = BatchAcknowledgement {
902 event_id: first.clone(),
903 accepted: true,
904 message: String::new(),
905 };
906 let rejected = BatchAcknowledgement {
907 event_id: second.clone(),
908 accepted: false,
909 message: "blocked".to_owned(),
910 };
911 let progress = track_batch_acknowledgements(
912 vec![first.clone(), second.clone()],
913 vec![rejected.clone(), accepted.clone(), accepted.clone()],
914 )?;
915 assert_eq!(
916 progress.states,
917 vec![BatchEventState::Accepted, BatchEventState::Rejected]
918 );
919 assert!(progress.complete);
920
921 let conflict = BatchAcknowledgement {
922 accepted: false,
923 ..accepted
924 };
925 assert!(
926 track_batch_acknowledgements(vec![first, second], vec![conflict, rejected]).is_ok()
927 );
928 let event_id = progress.event_ids[0].clone();
929 assert!(
930 track_batch_acknowledgements(
931 progress.event_ids,
932 vec![
933 BatchAcknowledgement {
934 event_id: event_id.clone(),
935 accepted: true,
936 message: String::new(),
937 },
938 BatchAcknowledgement {
939 event_id,
940 accepted: false,
941 message: String::new(),
942 },
943 ],
944 )
945 .is_err()
946 );
947 Ok(())
948 }
949
950 #[test]
951 fn nip42_plan_binds_challenge_relay_author_and_time() -> Result<(), SoftchatError> {
952 let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
953 let event =
954 plan_nip42_authentication(&identity, "challenge", "wss://relay.example", 1_000)?;
955 validate_nip42_authentication(
956 &event,
957 &identity.public_key(),
958 "challenge",
959 "wss://relay.example",
960 1_500,
961 )?;
962 assert!(
963 validate_nip42_authentication(
964 &event,
965 &identity.public_key(),
966 "wrong",
967 "wss://relay.example",
968 1_500,
969 )
970 .is_err()
971 );
972 assert!(
973 validate_nip42_authentication(
974 &event,
975 &identity.public_key(),
976 "challenge",
977 "wss://relay.example",
978 1_601,
979 )
980 .is_err()
981 );
982 Ok(())
983 }
984}