From ccfe4d88a3c238f97d098e66e0297836b99b93fa Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto Date: Sat, 22 Aug 2026 07:26:20 +0000 Subject: [PATCH] sdk(local_relay): refactor filter limits Refactor filter limit configuration and checks. Use `max_filter_limit` as the limit for each filter. The combination of `max_filter_limit` and `max_filters_per_req` determines the maximum number of query results per request. Deprecate `LocalRelayBuilder::max_query_results` and `LocalRelayBuilder::default_filter_limit` in favor of `LocalRelayBuilder::max_filters_per_req` and `LocalRelayBuilder::max_filter_limit`, respectively. Replaces https://github.com/nostrdevkit/nostr/pull/1457 Pull-Request: https://github.com/nostrdevkit/nostr/pull/1461 Signed-off-by: Yuki Kishimoto --- nostr-sdk/CHANGELOG.md | 6 ++ nostr-sdk/src/local_relay/builder.rs | 47 ++++----- nostr-sdk/src/local_relay/local/inner.rs | 119 +++++++---------------- nostr-sdk/src/local_relay/mod.rs | 114 +++++++++++++++++++++- 4 files changed, 173 insertions(+), 113 deletions(-) diff --git a/nostr-sdk/CHANGELOG.md b/nostr-sdk/CHANGELOG.md index dc65dd5e0..822d77b4f 100644 --- a/nostr-sdk/CHANGELOG.md +++ b/nostr-sdk/CHANGELOG.md @@ -27,6 +27,12 @@ --> +## Unreleased + +### Deprecated + +- Deprecate `LocalRelayBuilder::max_query_results` and `LocalRelayBuilder::default_filter_limit` (https://github.com/nostrdevkit/nostr/pull/1461) + ## v0.45.2 - 2026/08/19 ### Fixed diff --git a/nostr-sdk/src/local_relay/builder.rs b/nostr-sdk/src/local_relay/builder.rs index 5a4905cb7..8c9916d81 100644 --- a/nostr-sdk/src/local_relay/builder.rs +++ b/nostr-sdk/src/local_relay/builder.rs @@ -19,7 +19,6 @@ use super::local::LocalRelay; pub(super) const DEFAULT_MAX_PENDING_HANDSHAKES: usize = 128; pub(super) const DEFAULT_MAX_FILTERS_PER_REQ: usize = 20; pub(super) const DEFAULT_MAX_EVENT_SIZE: usize = 64 * 1024; -pub(super) const DEFAULT_MAX_QUERY_RESULTS: usize = 500; pub(super) const DEFAULT_MAX_NEGENTROPY_ITEMS: usize = 50_000; pub(super) const DEFAULT_MAX_SUBSCRIPTION_BYTES: usize = 1024 * 1024; pub(super) const DEFAULT_MAX_WEBSOCKET_MESSAGE_SIZE: usize = 5 * 1024 * 1024; @@ -296,18 +295,14 @@ pub struct LocalRelayBuilder { pub(crate) max_subid_length: usize, /// Max filters in one REQ pub(crate) max_filters_per_req: usize, + /// Maximum records that can be returned per filter + pub(crate) max_filter_limit: usize, /// Max total bytes retained by active subscriptions per connection pub(crate) max_subscription_bytes: usize, /// Max active negentropy subscriptions per connection pub(crate) max_negentropy_subscriptions: usize, /// Max total negentropy items retained per connection pub(crate) max_negentropy_items: usize, - /// Max filter's limit - pub(crate) max_filter_limit: Option, - /// Max aggregate query results - pub(crate) max_query_results: usize, - /// Default filter's limit if there is no limit - pub(crate) default_filter_limit: usize, /// Enables NIP-42 authentication for kind 1059 (GiftWrap), ensuring the /// authenticated pubkey is the only "p" tag pub(crate) auth_dm: bool, @@ -351,12 +346,10 @@ impl Default for LocalRelayBuilder { websocket_handshake_timeout: DEFAULT_WEBSOCKET_HANDSHAKE_TIMEOUT, max_subid_length: 250, max_filters_per_req: DEFAULT_MAX_FILTERS_PER_REQ, + max_filter_limit: 500, max_subscription_bytes: DEFAULT_MAX_SUBSCRIPTION_BYTES, max_negentropy_subscriptions: 10, max_negentropy_items: DEFAULT_MAX_NEGENTROPY_ITEMS, - max_filter_limit: Some(DEFAULT_MAX_QUERY_RESULTS), - max_query_results: DEFAULT_MAX_QUERY_RESULTS, - default_filter_limit: 500, auth_dm: false, min_pow: None, kinds_blacklist: HashSet::from(BLACKLISTED_KINDS), @@ -483,13 +476,20 @@ impl LocalRelayBuilder { self } - /// Sets the maximum number of filters in one REQ. Defaults to 20. + /// Sets the maximum number of filters allowed in a REQ. Defaults to 20. #[inline] pub fn max_filters_per_req(mut self, max: usize) -> Self { self.max_filters_per_req = max; self } + /// Sets the maximum limit for the filter. Defaults to 500. + #[inline] + pub fn max_filter_limit(mut self, max: usize) -> Self { + self.max_filter_limit = max; + self + } + /// Sets the maximum total bytes retained by active subscriptions per connection. /// Defaults to 1 MiB. #[inline] @@ -514,27 +514,16 @@ impl LocalRelayBuilder { self } - /// Sets the maximum limit for the filter. If the filter's limit exceeds - /// this value, it will fallback to this number. - #[inline] - pub fn max_filter_limit(mut self, max: usize) -> Self { - self.max_filter_limit = Some(max); + /// This is a no-op + #[deprecated(note = "Use max_filters_per_req instead")] + pub fn max_query_results(self, _max: usize) -> Self { self } - /// Sets the maximum aggregate number of events returned by one REQ. - /// Defaults to 500. - #[inline] - pub fn max_query_results(mut self, max: usize) -> Self { - self.max_query_results = max; - self - } - - /// Sets the default filter limit when no limit is specified. Defaults 500. - #[inline] - pub fn default_filter_limit(mut self, limit: usize) -> Self { - self.default_filter_limit = limit; - self + /// Sets the maximum limit for the filter. Defaults to 500. + #[deprecated(note = "Use max_filter_limit instead")] + pub fn default_filter_limit(self, limit: usize) -> Self { + self.max_filter_limit(limit) } /// If enabled, NIP-42 will be used for DMs, returning GiftWrap events for diff --git a/nostr-sdk/src/local_relay/local/inner.rs b/nostr-sdk/src/local_relay/local/inner.rs index b2f7544b5..a49036972 100644 --- a/nostr-sdk/src/local_relay/local/inner.rs +++ b/nostr-sdk/src/local_relay/local/inner.rs @@ -65,12 +65,10 @@ pub(super) struct InnerLocalRelay { websocket_handshake_timeout: Duration, max_subid_length: usize, max_filters_per_req: usize, + max_filter_limit: usize, max_subscription_bytes: usize, max_negentropy_subscriptions: usize, max_negentropy_items: usize, - max_filter_limit: Option, - max_query_results: usize, - default_filter_limit: usize, auth_dm: bool, min_pow: Option, // TODO: use AtomicU8 to allow to change it? kinds_blacklist: HashSet, @@ -121,12 +119,10 @@ impl InnerLocalRelay { websocket_handshake_timeout: builder.websocket_handshake_timeout, max_subid_length: builder.max_subid_length, max_filters_per_req: builder.max_filters_per_req, + max_filter_limit: builder.max_filter_limit, max_subscription_bytes: builder.max_subscription_bytes, max_negentropy_subscriptions: builder.max_negentropy_subscriptions, max_negentropy_items: builder.max_negentropy_items, - max_filter_limit: builder.max_filter_limit, - max_query_results: builder.max_query_results, - default_filter_limit: builder.default_filter_limit, auth_dm: builder.auth_dm, min_pow: builder.min_pow, kinds_blacklist: builder.kinds_blacklist, @@ -1247,9 +1243,8 @@ impl InnerLocalRelay { RelayMessage::Closed { subscription_id, message: Cow::Owned(format!( - "{}: REQ exceeds max filter count {}", - MachineReadablePrefix::RateLimited, - self.max_filters_per_req + "{}: too many filters", + MachineReadablePrefix::Blocked )), }, ) @@ -1316,18 +1311,36 @@ impl InnerLocalRelay { } } - // Cap each query and the merged result so multiple filters cannot multiply output. for filter in filters.iter_mut() { - let requested_limit = filter.limit.unwrap_or_else(|| { - filter - .ids - .as_ref() - .map_or(self.default_filter_limit, |ids| ids.len()) - }); - let per_filter_limit = self - .max_filter_limit - .map_or(requested_limit, |max| requested_limit.min(max)); - filter.limit = Some(per_filter_limit.min(self.max_query_results)); + match filter.limit { + Some(filter_limit) => { + // If the limit is greater than the max limit, use the max limit + if filter_limit > self.max_filter_limit { + filter.limit = Some(self.max_filter_limit) + } + } + // No limit set, if the filter has IDs, set the limit to the number of IDs, otherwise to the default limit. + None => match filter.ids.as_ref() { + Some(ids) => { + if ids.len() > self.max_filter_limit { + return send_msg( + ws_tx, + RelayMessage::Closed { + subscription_id, + message: Cow::Owned(format!( + "{}: requested too many event IDs", + MachineReadablePrefix::Blocked + )), + }, + ) + .await; + } + + filter.limit = Some(ids.len()); + } + None => filter.limit = Some(self.max_filter_limit), + }, + } } // Check if subscription has IDs @@ -1342,13 +1355,11 @@ impl InnerLocalRelay { let keys = Keys::generate(); - for _ in 0..500.min(self.max_query_results) { + for _ in 0..500 { events.insert(EventBuilder::new(Kind::TextNote, "Test").finalize(&keys)?); } events - } else if self.max_query_results == 0 { - BTreeSet::new() } else { let mut events: BTreeSet = BTreeSet::new(); @@ -1720,8 +1731,7 @@ mod tests { assert_eq!(relay.auth_events_per_minute, 30); assert_eq!(relay.messages_per_minute, 6_000); assert_eq!(relay.max_filters_per_req, 20); - assert_eq!(relay.max_filter_limit, Some(500)); - assert_eq!(relay.max_query_results, 500); + assert_eq!(relay.max_filter_limit, 500); assert_eq!(relay.max_subscription_bytes, 1024 * 1024); assert_eq!(relay.max_negentropy_items, 50_000); assert_eq!(config.max_message_size, Some(5 * 1024 * 1024)); @@ -1926,7 +1936,7 @@ mod tests { subscription_id, message, } if subscription_id.as_str() == "filters" - && message == "rate-limited: REQ exceeds max filter count 1" + && message == "blocked: too many filters" )); } @@ -2209,63 +2219,6 @@ mod tests { )); } - #[tokio::test] - async fn req_results_are_bounded_and_streamed() { - let relay = InnerLocalRelay::new( - LocalRelayBuilder::default() - .max_filter_limit(1) - .max_query_results(1), - ); - for content in ["one", "two"] { - let event = EventBuilder::new(Kind::TextNote, content) - .finalize(&Keys::generate()) - .unwrap(); - relay.database.save_event(&event).await.unwrap(); - } - - let (server_stream, client_stream) = tokio::io::duplex(16 * 1024); - let server = WebSocketStream::from_raw_socket(server_stream, Role::Server, None).await; - let mut client = WebSocketStream::from_raw_socket(client_stream, Role::Client, None).await; - let (mut server_tx, _) = server.split(); - let mut session = session(None); - session.query_tokens = Tokens::new(1); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); - - relay - .handle_client_msg( - &mut session, - &mut server_tx, - ClientMessage::Req { - subscription_id: Cow::Owned(SubscriptionId::new("bounded")), - filters: vec![Cow::Owned(Filter::new().limit(usize::MAX))], - }, - &addr, - 32, - ) - .await - .unwrap(); - - let event = client.next().await.unwrap().unwrap(); - let eose = client.next().await.unwrap().unwrap(); - assert!(matches!( - event, - Message::Text(json) - if matches!( - RelayMessage::from_json(json.as_bytes()).unwrap(), - RelayMessage::Event { .. } - ) - )); - assert!(matches!( - eose, - Message::Text(json) - if matches!( - RelayMessage::from_json(json.as_bytes()).unwrap(), - RelayMessage::EndOfStoredEvents(subscription_id) - if subscription_id.as_str() == "bounded" - ) - )); - } - #[test] fn gift_wrap_auth_restricts_filters_without_kinds() { let relay = InnerLocalRelay::new(LocalRelayBuilder::default().auth_dm(true)); diff --git a/nostr-sdk/src/local_relay/mod.rs b/nostr-sdk/src/local_relay/mod.rs index 0cc6fb9ad..80a053871 100644 --- a/nostr-sdk/src/local_relay/mod.rs +++ b/nostr-sdk/src/local_relay/mod.rs @@ -17,14 +17,16 @@ mod tests { use std::future::Future; use std::pin::Pin; - use nostr::event::{EventBuilder, FinalizeEvent, Kind, Tag}; + use nostr::event::{EventBuilder, EventId, FinalizeEvent, Kind, Tag}; use nostr::filter::Filter; use nostr::key::{Keys, PublicKey}; use nostr::nips::nip17::PrivateDirectMessageBuilder; use nostr_memory::MemoryDatabase; + use tokio_stream::StreamExt; use super::*; use crate::client::Client; + use crate::error::Error; const UPDATE_TAG: &str = "updated"; @@ -232,4 +234,114 @@ mod tests { output.failed.values().next().unwrap() ); } + + #[tokio::test] + async fn test_max_filter_limit() { + let relay = LocalRelay::builder() + .database(MemoryDatabase::unbounded()) + .max_filter_limit(3) + .build(); + relay.run().await.unwrap(); + + let keys = Keys::generate(); + let client = Client::default(); + + client + .add_relay(relay.url().await) + .and_connect() + .await + .unwrap(); + + for i in 0..20 { + client + .send_event( + &EventBuilder::new( + if i % 2 == 1 { + Kind::TextNote + } else { + Kind::Comment + }, + i.to_string(), + ) + .finalize(&keys) + .unwrap(), + ) + .await + .unwrap(); + } + + let result = client + .fetch_events([ + Filter::new().kind(Kind::TextNote), + Filter::new().kind(Kind::Comment), + ]) + .await + .unwrap(); + + assert_eq!(6, result.len(), "max result is 6"); + assert_eq!( + 3, + result.iter().filter(|e| e.kind == Kind::TextNote).count() + ); + assert_eq!(3, result.iter().filter(|e| e.kind == Kind::Comment).count()); + + let mut stream = client + .stream_events([Filter::new().ids([ + EventId::from_hex( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(), + EventId::from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .unwrap(), + EventId::from_hex( + "0000000000000000000000000000000000000000000000000000000000000002", + ) + .unwrap(), + EventId::from_hex( + "0000000000000000000000000000000000000000000000000000000000000003", + ) + .unwrap(), + ])]) + .await + .unwrap(); + + let (_, res) = stream.next().await.unwrap(); + assert_eq!( + res.unwrap_err(), + Error::relay_msg(String::from("blocked: requested too many event IDs")) + ); + } + + #[tokio::test] + async fn test_max_req_filter_size() { + let relay = LocalRelay::builder() + .database(MemoryDatabase::unbounded()) + .max_filters_per_req(1) + .build(); + relay.run().await.unwrap(); + + let client = Client::default(); + + client + .add_relay(relay.url().await) + .and_connect() + .await + .unwrap(); + + let mut stream = client + .stream_events([ + Filter::new().kind(Kind::TextNote), + Filter::new().kind(Kind::Comment), + ]) + .await + .unwrap(); + + let (_, res) = stream.next().await.unwrap(); + assert_eq!( + res.unwrap_err(), + Error::relay_msg(String::from("blocked: too many filters")) + ); + } }