event_head.rs (16616B)
1 #![forbid(unsafe_code)] 2 3 #[cfg(not(feature = "std"))] 4 use alloc::{string::String, vec::Vec}; 5 6 use crate::RadrootsNostrEvent; 7 use crate::contract::{ 8 RadrootsContractMatchError, RadrootsEventClass, RadrootsEventContract, identify_event_contract, 9 }; 10 use crate::ids::{RadrootsDTag, RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey}; 11 use crate::tags::TAG_D; 12 13 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 14 pub enum RadrootsEventHeadCoordinate { 15 Replaceable { 16 kind: u32, 17 pubkey: RadrootsPublicKey, 18 }, 19 Addressable { 20 kind: u32, 21 pubkey: RadrootsPublicKey, 22 d_tag: RadrootsDTag, 23 }, 24 } 25 26 #[derive(Clone, Debug, PartialEq, Eq)] 27 pub struct RadrootsEventHeadCandidate { 28 pub coordinate: RadrootsEventHeadCoordinate, 29 pub event_id: RadrootsEventId, 30 pub created_at: u32, 31 } 32 33 #[derive(Clone, Debug, PartialEq, Eq)] 34 pub struct RadrootsCurrentEventHead { 35 pub coordinate: RadrootsEventHeadCoordinate, 36 pub event_id: RadrootsEventId, 37 pub created_at: u32, 38 } 39 40 impl From<RadrootsEventHeadCandidate> for RadrootsCurrentEventHead { 41 fn from(candidate: RadrootsEventHeadCandidate) -> Self { 42 Self { 43 coordinate: candidate.coordinate, 44 event_id: candidate.event_id, 45 created_at: candidate.created_at, 46 } 47 } 48 } 49 50 #[derive(Clone, Debug, PartialEq, Eq)] 51 pub enum RadrootsEventHeadMalformed { 52 InvalidEventId(RadrootsIdParseError), 53 InvalidPubkey(RadrootsIdParseError), 54 MissingDTag, 55 InvalidDTag(RadrootsIdParseError), 56 } 57 58 #[derive(Clone, Debug, PartialEq, Eq)] 59 pub enum RadrootsEventHeadCandidateResult { 60 Candidate(RadrootsEventHeadCandidate), 61 NotHeadSelected, 62 NotPersisted, 63 Malformed(RadrootsEventHeadMalformed), 64 } 65 66 #[derive(Clone, Debug, PartialEq, Eq)] 67 pub enum RadrootsEventHeadDecision { 68 Applied(RadrootsCurrentEventHead), 69 SkippedDuplicate, 70 SkippedOlder, 71 SkippedSameTimestampHigherEventId, 72 CoordinateMismatch, 73 } 74 75 pub fn event_head_candidate_for_class( 76 event: &RadrootsNostrEvent, 77 class: RadrootsEventClass, 78 ) -> RadrootsEventHeadCandidateResult { 79 match class { 80 RadrootsEventClass::Regular => RadrootsEventHeadCandidateResult::NotHeadSelected, 81 RadrootsEventClass::Ephemeral => RadrootsEventHeadCandidateResult::NotPersisted, 82 RadrootsEventClass::Replaceable | RadrootsEventClass::Addressable => { 83 let event_id = match RadrootsEventId::parse(&event.id) { 84 Ok(event_id) => event_id, 85 Err(error) => { 86 return RadrootsEventHeadCandidateResult::Malformed( 87 RadrootsEventHeadMalformed::InvalidEventId(error), 88 ); 89 } 90 }; 91 let pubkey = match RadrootsPublicKey::parse(&event.author) { 92 Ok(pubkey) => pubkey, 93 Err(error) => { 94 return RadrootsEventHeadCandidateResult::Malformed( 95 RadrootsEventHeadMalformed::InvalidPubkey(error), 96 ); 97 } 98 }; 99 let coordinate = match class { 100 RadrootsEventClass::Replaceable => RadrootsEventHeadCoordinate::Replaceable { 101 kind: event.kind, 102 pubkey, 103 }, 104 RadrootsEventClass::Addressable => { 105 let Some(d_tag) = first_tag_value(&event.tags, TAG_D) else { 106 return RadrootsEventHeadCandidateResult::Malformed( 107 RadrootsEventHeadMalformed::MissingDTag, 108 ); 109 }; 110 let d_tag = match RadrootsDTag::parse(d_tag) { 111 Ok(d_tag) => d_tag, 112 Err(error) => { 113 return RadrootsEventHeadCandidateResult::Malformed( 114 RadrootsEventHeadMalformed::InvalidDTag(error), 115 ); 116 } 117 }; 118 RadrootsEventHeadCoordinate::Addressable { 119 kind: event.kind, 120 pubkey, 121 d_tag, 122 } 123 } 124 RadrootsEventClass::Regular | RadrootsEventClass::Ephemeral => unreachable!(), 125 }; 126 RadrootsEventHeadCandidateResult::Candidate(RadrootsEventHeadCandidate { 127 coordinate, 128 event_id, 129 created_at: event.created_at, 130 }) 131 } 132 } 133 } 134 135 pub fn event_head_candidate_for_contract( 136 event: &RadrootsNostrEvent, 137 contract: &RadrootsEventContract, 138 ) -> RadrootsEventHeadCandidateResult { 139 event_head_candidate_for_class(event, contract.class) 140 } 141 142 pub fn event_head_candidate_for_event( 143 event: &RadrootsNostrEvent, 144 ) -> Result<RadrootsEventHeadCandidateResult, RadrootsContractMatchError> { 145 let contract = identify_event_contract(event.kind, &event.tags, &event.content)?; 146 Ok(event_head_candidate_for_contract(event, contract)) 147 } 148 149 pub fn select_event_head( 150 candidate: RadrootsEventHeadCandidate, 151 current: Option<&RadrootsCurrentEventHead>, 152 ) -> RadrootsEventHeadDecision { 153 let Some(current) = current else { 154 return RadrootsEventHeadDecision::Applied(candidate.into()); 155 }; 156 if candidate.coordinate != current.coordinate { 157 return RadrootsEventHeadDecision::CoordinateMismatch; 158 } 159 if candidate.event_id == current.event_id { 160 return RadrootsEventHeadDecision::SkippedDuplicate; 161 } 162 if candidate.created_at > current.created_at { 163 return RadrootsEventHeadDecision::Applied(candidate.into()); 164 } 165 if candidate.created_at < current.created_at { 166 return RadrootsEventHeadDecision::SkippedOlder; 167 } 168 if candidate.event_id < current.event_id { 169 RadrootsEventHeadDecision::Applied(candidate.into()) 170 } else { 171 RadrootsEventHeadDecision::SkippedSameTimestampHigherEventId 172 } 173 } 174 175 fn first_tag_value<'a>(tags: &'a [Vec<String>], name: &str) -> Option<&'a str> { 176 tags.iter() 177 .find(|tag| tag.first().map(String::as_str) == Some(name)) 178 .and_then(|tag| tag.get(1)) 179 .map(String::as_str) 180 } 181 182 #[cfg(test)] 183 mod tests { 184 use super::*; 185 use crate::contract::RadrootsContractMatchError; 186 use crate::kinds::{ 187 KIND_FOLLOW, KIND_LIST_SET_GENERIC, KIND_ORDER_REQUEST, KIND_POST, KIND_PROFILE, 188 }; 189 190 fn hex_64(character: char) -> String { 191 core::iter::repeat_n(character, 64).collect() 192 } 193 194 fn event( 195 kind: u32, 196 id: &str, 197 author: &str, 198 created_at: u32, 199 tags: Vec<Vec<String>>, 200 ) -> RadrootsNostrEvent { 201 RadrootsNostrEvent { 202 id: id.to_string(), 203 author: author.to_string(), 204 created_at, 205 kind, 206 tags, 207 content: String::new(), 208 sig: String::new(), 209 } 210 } 211 212 fn event_with_content( 213 kind: u32, 214 id: &str, 215 author: &str, 216 created_at: u32, 217 tags: Vec<Vec<String>>, 218 content: &str, 219 ) -> RadrootsNostrEvent { 220 let mut event = event(kind, id, author, created_at, tags); 221 event.content = content.to_string(); 222 event 223 } 224 225 fn candidate(id: char, created_at: u32) -> RadrootsEventHeadCandidate { 226 expect_candidate(event_head_candidate_for_class( 227 &event(10002, &hex_64(id), &hex_64('a'), created_at, Vec::new()), 228 RadrootsEventClass::Replaceable, 229 )) 230 } 231 232 fn expect_candidate(result: RadrootsEventHeadCandidateResult) -> RadrootsEventHeadCandidate { 233 match result { 234 RadrootsEventHeadCandidateResult::Candidate(candidate) => candidate, 235 other => panic!("expected candidate: {other:?}"), 236 } 237 } 238 239 #[test] 240 fn regular_and_ephemeral_events_do_not_create_heads() { 241 let event = event(1, &hex_64('1'), &hex_64('a'), 1, Vec::new()); 242 assert_eq!( 243 event_head_candidate_for_class(&event, RadrootsEventClass::Regular), 244 RadrootsEventHeadCandidateResult::NotHeadSelected 245 ); 246 assert_eq!( 247 event_head_candidate_for_class(&event, RadrootsEventClass::Ephemeral), 248 RadrootsEventHeadCandidateResult::NotPersisted 249 ); 250 } 251 252 #[test] 253 fn replaceable_events_use_kind_and_pubkey_coordinates() { 254 let event = event(10002, &hex_64('1'), &hex_64('a'), 5, Vec::new()); 255 let candidate = expect_candidate(event_head_candidate_for_class( 256 &event, 257 RadrootsEventClass::Replaceable, 258 )); 259 assert_eq!( 260 candidate.coordinate, 261 RadrootsEventHeadCoordinate::Replaceable { 262 kind: 10002, 263 pubkey: RadrootsPublicKey::parse(hex_64('a')).unwrap() 264 } 265 ); 266 assert_eq!(candidate.created_at, 5); 267 } 268 269 #[test] 270 fn addressable_events_use_kind_pubkey_and_d_tag_coordinates() { 271 let event = event( 272 30023, 273 &hex_64('2'), 274 &hex_64('b'), 275 7, 276 vec![vec![TAG_D.to_string(), "article-1".to_string()]], 277 ); 278 let candidate = expect_candidate(event_head_candidate_for_class( 279 &event, 280 RadrootsEventClass::Addressable, 281 )); 282 assert_eq!( 283 candidate.coordinate, 284 RadrootsEventHeadCoordinate::Addressable { 285 kind: 30023, 286 pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(), 287 d_tag: RadrootsDTag::parse("article-1").unwrap() 288 } 289 ); 290 } 291 292 #[test] 293 fn addressable_events_require_valid_d_tags() { 294 let missing = event(30023, &hex_64('2'), &hex_64('b'), 7, Vec::new()); 295 assert_eq!( 296 event_head_candidate_for_class(&missing, RadrootsEventClass::Addressable), 297 RadrootsEventHeadCandidateResult::Malformed(RadrootsEventHeadMalformed::MissingDTag) 298 ); 299 300 let invalid = event( 301 30023, 302 &hex_64('2'), 303 &hex_64('b'), 304 7, 305 vec![vec![TAG_D.to_string(), "bad d".to_string()]], 306 ); 307 assert!(matches!( 308 event_head_candidate_for_class(&invalid, RadrootsEventClass::Addressable), 309 RadrootsEventHeadCandidateResult::Malformed(RadrootsEventHeadMalformed::InvalidDTag(_)) 310 )); 311 } 312 313 #[test] 314 fn malformed_candidates_report_invalid_event_ids_and_pubkeys() { 315 let bad_event_id = event(10002, "not-hex", &hex_64('a'), 1, Vec::new()); 316 assert!(matches!( 317 event_head_candidate_for_class(&bad_event_id, RadrootsEventClass::Replaceable), 318 RadrootsEventHeadCandidateResult::Malformed( 319 RadrootsEventHeadMalformed::InvalidEventId(_) 320 ) 321 )); 322 323 let bad_pubkey = event(10002, &hex_64('1'), "not-hex", 1, Vec::new()); 324 assert!(matches!( 325 event_head_candidate_for_class(&bad_pubkey, RadrootsEventClass::Replaceable), 326 RadrootsEventHeadCandidateResult::Malformed(RadrootsEventHeadMalformed::InvalidPubkey( 327 _ 328 )) 329 )); 330 } 331 332 #[test] 333 fn event_head_selection_uses_nip01_time_and_lowest_id_rules() { 334 let current: RadrootsCurrentEventHead = candidate('3', 10).into(); 335 336 assert!(matches!( 337 select_event_head(candidate('1', 1), None), 338 RadrootsEventHeadDecision::Applied(_) 339 )); 340 assert!(matches!( 341 select_event_head(candidate('4', 11), Some(¤t)), 342 RadrootsEventHeadDecision::Applied(_) 343 )); 344 assert_eq!( 345 select_event_head(candidate('2', 9), Some(¤t)), 346 RadrootsEventHeadDecision::SkippedOlder 347 ); 348 assert_eq!( 349 select_event_head(candidate('3', 10), Some(¤t)), 350 RadrootsEventHeadDecision::SkippedDuplicate 351 ); 352 assert!(matches!( 353 select_event_head(candidate('2', 10), Some(¤t)), 354 RadrootsEventHeadDecision::Applied(_) 355 )); 356 assert_eq!( 357 select_event_head(candidate('4', 10), Some(¤t)), 358 RadrootsEventHeadDecision::SkippedSameTimestampHigherEventId 359 ); 360 } 361 362 #[test] 363 fn event_head_selection_rejects_coordinate_mismatch() { 364 let current: RadrootsCurrentEventHead = candidate('3', 10).into(); 365 let other = event_head_candidate_for_class( 366 &event( 367 30023, 368 &hex_64('2'), 369 &hex_64('a'), 370 11, 371 vec![vec![TAG_D.to_string(), "article".to_string()]], 372 ), 373 RadrootsEventClass::Addressable, 374 ); 375 let other = expect_candidate(other); 376 assert_eq!( 377 select_event_head(other, Some(¤t)), 378 RadrootsEventHeadDecision::CoordinateMismatch 379 ); 380 } 381 382 #[test] 383 fn contract_bridge_uses_replaceable_event_classes() { 384 let event = event(KIND_FOLLOW, &hex_64('1'), &hex_64('a'), 1, Vec::new()); 385 let candidate = expect_candidate(event_head_candidate_for_event(&event).expect("contract")); 386 assert_eq!( 387 candidate.coordinate, 388 RadrootsEventHeadCoordinate::Replaceable { 389 kind: KIND_FOLLOW, 390 pubkey: RadrootsPublicKey::parse(hex_64('a')).unwrap() 391 } 392 ); 393 } 394 395 #[test] 396 fn contract_bridge_uses_addressable_event_classes() { 397 let event = event( 398 KIND_LIST_SET_GENERIC, 399 &hex_64('2'), 400 &hex_64('b'), 401 1, 402 vec![vec![TAG_D.to_string(), "member_of.farms".to_string()]], 403 ); 404 let candidate = expect_candidate(event_head_candidate_for_event(&event).expect("contract")); 405 assert_eq!( 406 candidate.coordinate, 407 RadrootsEventHeadCoordinate::Addressable { 408 kind: KIND_LIST_SET_GENERIC, 409 pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(), 410 d_tag: RadrootsDTag::parse("member_of.farms").unwrap() 411 } 412 ); 413 } 414 415 #[test] 416 fn contract_bridge_uses_profile_replaceable_heads() { 417 let profile = event_with_content( 418 KIND_PROFILE, 419 &hex_64('3'), 420 &hex_64('c'), 421 1, 422 Vec::new(), 423 r#"{"name":"Alice"}"#, 424 ); 425 let candidate = 426 expect_candidate(event_head_candidate_for_event(&profile).expect("profile contract")); 427 assert_eq!( 428 candidate.coordinate, 429 RadrootsEventHeadCoordinate::Replaceable { 430 kind: KIND_PROFILE, 431 pubkey: RadrootsPublicKey::parse(hex_64('c')).unwrap() 432 } 433 ); 434 } 435 436 #[test] 437 fn contract_bridge_keeps_order_events_out_of_head_selection() { 438 let order = event_with_content( 439 KIND_ORDER_REQUEST, 440 &hex_64('4'), 441 &hex_64('d'), 442 1, 443 vec![ 444 vec!["p".to_string(), hex_64('e')], 445 vec!["a".to_string(), format!("30402:{}:listing-1", hex_64('f'))], 446 vec![TAG_D.to_string(), "order-1".to_string()], 447 ], 448 "{}", 449 ); 450 assert_eq!( 451 event_head_candidate_for_event(&order).expect("order contract"), 452 RadrootsEventHeadCandidateResult::NotHeadSelected 453 ); 454 } 455 456 #[test] 457 fn contract_bridge_reports_unsupported_and_malformed_shapes() { 458 let unsupported = event(999_999, &hex_64('5'), &hex_64('a'), 1, Vec::new()); 459 assert_eq!( 460 event_head_candidate_for_event(&unsupported), 461 Err(RadrootsContractMatchError::UnsupportedKind(999_999)) 462 ); 463 464 let malformed_addressable = event( 465 KIND_LIST_SET_GENERIC, 466 &hex_64('6'), 467 &hex_64('a'), 468 1, 469 Vec::new(), 470 ); 471 assert_eq!( 472 event_head_candidate_for_event(&malformed_addressable), 473 Err(RadrootsContractMatchError::UnsupportedShape( 474 KIND_LIST_SET_GENERIC 475 )) 476 ); 477 478 let regular_with_d_tag = event( 479 KIND_POST, 480 &hex_64('7'), 481 &hex_64('a'), 482 1, 483 vec![vec![TAG_D.to_string(), "not-a-head".to_string()]], 484 ); 485 assert_eq!( 486 event_head_candidate_for_event(®ular_with_d_tag).expect("post contract"), 487 RadrootsEventHeadCandidateResult::NotHeadSelected 488 ); 489 } 490 }