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:
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");