lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit c827f10a327fb1a086854c6828884257bf7f48cb
parent 0c70696967469c80374cc52df38785494f5ed380
Author: triesap <tyson@radroots.org>
Date:   Wed,  1 Jul 2026 10:49:05 +0000

relay_transport: reject out-of-filter fetch events

- enforce request filters before relay fetch ingestion mutates stores
- classify out-of-filter events separately from malformed and unsupported data
- keep relay fetch receipts explicit about dropped relay responses
- validate filter rejection with relay transport regression coverage

Diffstat:
Mcrates/relay_transport/src/fetch.rs | 35++++++++++++++++++++++++++++++++++-
Mcrates/relay_transport/tests/transport.rs | 102++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 133 insertions(+), 4 deletions(-)

diff --git a/crates/relay_transport/src/fetch.rs b/crates/relay_transport/src/fetch.rs @@ -3,7 +3,7 @@ use crate::{RadrootsRelayOutcome, RadrootsRelayTransportError}; use core::time::Duration; use futures::future::BoxFuture; -use nostr::JsonUtil; +use nostr::{JsonUtil, filter::MatchEventOptions}; use radroots_event_store::{ RadrootsEventContractStatus, RadrootsEventIngest, RadrootsEventStore, RadrootsRelayObservation, RadrootsRelayObservationType, @@ -121,6 +121,7 @@ pub struct RadrootsRelayFetchEventReceipt { pub duplicate: bool, pub unsupported: bool, pub malformed: bool, + pub out_of_filter: bool, pub projection_eligible: bool, pub verification_status: Option<String>, pub message: Option<String>, @@ -131,6 +132,7 @@ pub struct RadrootsRelayFetchReceipt { pub inserted_count: usize, pub duplicate_count: usize, pub malformed_count: usize, + pub out_of_filter_count: usize, pub unsupported_count: usize, pub eose_count: usize, pub closed_count: usize, @@ -156,11 +158,13 @@ where { let mode = request.mode; let max_events = request.max_events; + let filters = request.filters.clone(); let items = adapter.fetch(request).await?; let mut receipt = RadrootsRelayFetchReceipt { inserted_count: 0, duplicate_count: 0, malformed_count: 0, + out_of_filter_count: 0, unsupported_count: 0, eose_count: 0, closed_count: 0, @@ -190,12 +194,29 @@ where duplicate: false, unsupported: false, malformed: true, + out_of_filter: false, projection_eligible: false, verification_status: None, message: Some("event JSON parse failed".to_owned()), }); continue; }; + if !relay_fetch_event_matches_filters(&filters, &raw_event) { + receipt.out_of_filter_count += 1; + receipt.events.push(RadrootsRelayFetchEventReceipt { + relay_url, + event_id: Some(raw_event.id.to_hex()), + inserted: false, + duplicate: false, + unsupported: false, + malformed: false, + out_of_filter: true, + projection_eligible: false, + verification_status: None, + message: Some("event did not match relay fetch filters".to_owned()), + }); + continue; + } let event = radroots_event_from_nostr(&raw_event); let observation_type = match mode { RadrootsRelayFetchMode::Fetch => RadrootsRelayObservationType::Fetch, @@ -229,6 +250,7 @@ where duplicate: !store_receipt.inserted, unsupported, malformed: false, + out_of_filter: false, projection_eligible: store_receipt.projection_eligible, verification_status: Some( store_receipt.verification_status.as_str().to_owned(), @@ -245,6 +267,7 @@ where duplicate: false, unsupported: false, malformed: true, + out_of_filter: false, projection_eligible: false, verification_status: None, message: Some(error.to_string()), @@ -284,6 +307,16 @@ where Ok(receipt) } +fn relay_fetch_event_matches_filters( + filters: &[RadrootsNostrFilter], + event: &RadrootsNostrEvent, +) -> bool { + filters.is_empty() + || filters + .iter() + .any(|filter| filter.match_event(event, MatchEventOptions::new())) +} + #[derive(Clone, Copy, Debug, Default)] pub struct RadrootsNostrClientFetchAdapter; diff --git a/crates/relay_transport/tests/transport.rs b/crates/relay_transport/tests/transport.rs @@ -4,7 +4,8 @@ use radroots_event_store::{RadrootsEventStore, RadrootsEventVerificationStatus}; use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}; use radroots_events::kinds::KIND_POST; use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_nostr_build_event, + RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, + RadrootsNostrTimestamp, radroots_nostr_build_event, radroots_nostr_filter_tag, radroots_nostr_sign_frozen_draft, }; use radroots_outbox::{ @@ -68,11 +69,19 @@ fn fixture_keys() -> RadrootsNostrKeys { } fn signed_post(content: &str) -> RadrootsSignedNostrEvent { + signed_event_with_kind_and_hashtag(content, KIND_POST, "soil") +} + +fn signed_event_with_kind_and_hashtag( + content: &str, + kind: u32, + hashtag: &str, +) -> RadrootsSignedNostrEvent { let draft = RadrootsFrozenEventDraft::new( "radroots.social.post.v1", - KIND_POST, + kind, 1_700_000_000, - vec![vec!["t".to_owned(), "soil".to_owned()]], + vec![vec!["t".to_owned(), hashtag.to_owned()]], content, FIXTURE_ALICE_PUBLIC_KEY_HEX, ) @@ -80,6 +89,18 @@ fn signed_post(content: &str) -> RadrootsSignedNostrEvent { radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event") } +fn signed_raw_event_with_kind_and_hashtag(content: &str, kind: u32, hashtag: &str) -> nostr::Event { + radroots_nostr_build_event( + kind, + content, + vec![vec!["t".to_owned(), hashtag.to_owned()]], + ) + .expect("event builder") + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(&fixture_keys()) + .expect("signed event") +} + async fn complete_claimed_signing( outbox: &RadrootsOutbox, claimed: &RadrootsOutboxClaimedEvent, @@ -538,6 +559,81 @@ async fn fetch_ingests_events_and_records_relay_observations() { } #[tokio::test] +async fn fetch_rejects_out_of_filter_events_before_store_mutation() { + let accepted = signed_post("filter match"); + let wrong_tag = signed_event_with_kind_and_hashtag("filter wrong tag", KIND_POST, "compost"); + let wrong_kind = signed_raw_event_with_kind_and_hashtag("filter wrong kind", 999, "soil"); + let wrong_kind_event_id = wrong_kind.id.to_hex(); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: wrong_tag.raw_json.clone(), + observed_at_ms: 1_005, + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: accepted.raw_json.clone(), + observed_at_ms: 1_006, + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + raw_json: wrong_kind.as_json(), + observed_at_ms: 1_007, + }, + RadrootsRelayFetchItem::Eose { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + }, + ]); + let filter = radroots_nostr_filter_tag( + RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom(KIND_POST as u16)) + .limit(10), + "t", + vec!["soil".to_owned()], + ) + .expect("filter"); + + let receipt = fetch_and_ingest_relay_events( + &adapter, + &store, + RadrootsRelayFetchRequest::fetch(1_005, 10).with_filters([filter]), + ) + .await + .expect("fetch ingest"); + + assert_eq!(receipt.inserted_count, 1); + assert_eq!(receipt.out_of_filter_count, 2); + assert_eq!(receipt.malformed_count, 0); + assert_eq!(receipt.unsupported_count, 0); + assert_eq!(receipt.events.len(), 3); + assert!(receipt.events[0].out_of_filter); + assert!(!receipt.events[1].out_of_filter); + assert!(receipt.events[2].out_of_filter); + assert!( + store + .get_event(accepted.id.as_str()) + .await + .expect("accepted lookup") + .is_some() + ); + assert!( + store + .get_event(wrong_tag.id.as_str()) + .await + .expect("wrong tag lookup") + .is_none() + ); + assert!( + store + .get_event(wrong_kind_event_id.as_str()) + .await + .expect("wrong kind lookup") + .is_none() + ); +} + +#[tokio::test] async fn fetch_event_cap_preserves_later_control_outcomes() { let first = signed_post("first capped event"); let skipped = signed_post("skipped capped event");