aztec_pxe/sync/
event_filter.rs

1//! Private event filter validation and sanitization.
2//!
3//! Ports the TS `PrivateEventFilterValidator` which ensures filter
4//! parameters are valid relative to the current anchor block.
5
6use aztec_core::error::Error;
7use aztec_core::types::AztecAddress;
8
9use crate::stores::private_event_store::PrivateEventQueryFilter;
10
11use aztec_pxe_client::PrivateEventFilter;
12
13/// Validates and sanitizes private event filters.
14///
15/// Ensures block ranges are valid and within the anchor block boundary.
16pub struct PrivateEventFilterValidator {
17    /// The current anchor block number.
18    anchor_block_number: u64,
19}
20
21impl PrivateEventFilterValidator {
22    pub fn new(anchor_block_number: u64) -> Self {
23        Self {
24            anchor_block_number,
25        }
26    }
27
28    /// Validate and convert a `PrivateEventFilter` to a `PrivateEventQueryFilter`.
29    ///
30    /// Validation rules (matching upstream TS implementation):
31    /// - At least one scope is required
32    /// - `from_block` defaults to 1 (genesis), clamped to at least 1
33    /// - `to_block` defaults to anchor_block_number + 1 (exclusive upper bound)
34    /// - `to_block` must not exceed anchor_block_number + 1 (rejects, not clamps)
35    /// - `from_block` must be less than `to_block`
36    pub fn validate(&self, filter: &PrivateEventFilter) -> Result<PrivateEventQueryFilter, Error> {
37        // Upstream rejects empty scopes: "At least one scope is required to get private events"
38        if filter.scopes.is_empty() {
39            return Err(Error::InvalidData(
40                "at least one scope is required to get private events".into(),
41            ));
42        }
43
44        let from_block = filter.from_block.unwrap_or(1).max(1);
45
46        // to_block is an exclusive upper bound; default to anchor + 1
47        let to_block = filter.to_block.unwrap_or(self.anchor_block_number + 1);
48
49        // Validate rather than clamp: if caller requests beyond anchor, reject
50        if to_block > self.anchor_block_number + 1 {
51            return Err(Error::InvalidData(format!(
52                "to_block ({to_block}) exceeds anchor block + 1 ({})",
53                self.anchor_block_number + 1
54            )));
55        }
56
57        if from_block >= to_block {
58            return Err(Error::InvalidData(format!(
59                "invalid block range: from_block={from_block} >= to_block={to_block}"
60            )));
61        }
62
63        // Convert exclusive to_block to inclusive for our store
64        let inclusive_to_block = to_block - 1;
65
66        Ok(PrivateEventQueryFilter {
67            contract_address: filter.contract_address,
68            from_block: Some(from_block),
69            to_block: Some(inclusive_to_block),
70            scopes: filter.scopes.clone(),
71            tx_hash: filter.tx_hash,
72        })
73    }
74}
75
76/// Convert a `PrivateEventFilter` to `PrivateEventQueryFilter` without anchor
77/// block validation (for testing or when anchor is unknown).
78pub fn to_query_filter_unchecked(filter: &PrivateEventFilter) -> PrivateEventQueryFilter {
79    PrivateEventQueryFilter {
80        contract_address: filter.contract_address,
81        from_block: filter.from_block,
82        to_block: filter.to_block,
83        scopes: filter.scopes.clone(),
84        tx_hash: filter.tx_hash,
85    }
86}
87
88/// Create a default "all events" query filter for a contract.
89pub fn all_events_filter(contract_address: AztecAddress) -> PrivateEventQueryFilter {
90    PrivateEventQueryFilter {
91        contract_address,
92        from_block: None,
93        to_block: None,
94        scopes: vec![],
95        tx_hash: None,
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn make_filter(from: Option<u64>, to: Option<u64>) -> PrivateEventFilter {
104        PrivateEventFilter {
105            contract_address: AztecAddress::from(1u64),
106            from_block: from,
107            to_block: to,
108            tx_hash: None,
109            after_log: None,
110            scopes: vec![AztecAddress::from(99u64)], // at least one scope required
111        }
112    }
113
114    #[test]
115    fn defaults_block_range_to_full() {
116        let validator = PrivateEventFilterValidator::new(10);
117        let result = validator.validate(&make_filter(None, None)).unwrap();
118        assert_eq!(result.from_block, Some(1));
119        assert_eq!(result.to_block, Some(10)); // inclusive = exclusive - 1
120    }
121
122    #[test]
123    fn rejects_to_block_beyond_anchor() {
124        let validator = PrivateEventFilterValidator::new(5);
125        let result = validator.validate(&make_filter(Some(1), Some(100)));
126        assert!(result.is_err(), "to_block beyond anchor should be rejected");
127    }
128
129    #[test]
130    fn clamps_from_block_to_at_least_1() {
131        let validator = PrivateEventFilterValidator::new(10);
132        let result = validator.validate(&make_filter(Some(0), None)).unwrap();
133        assert_eq!(result.from_block, Some(1));
134    }
135
136    #[test]
137    fn rejects_invalid_range() {
138        let validator = PrivateEventFilterValidator::new(5);
139        // from=10, to defaults to 6 (anchor+1), so 10 >= 6 -> error
140        let result = validator.validate(&make_filter(Some(10), None));
141        assert!(result.is_err());
142    }
143
144    #[test]
145    fn rejects_empty_scopes() {
146        let validator = PrivateEventFilterValidator::new(10);
147        let filter = PrivateEventFilter {
148            contract_address: AztecAddress::from(1u64),
149            from_block: None,
150            to_block: None,
151            tx_hash: None,
152            after_log: None,
153            scopes: vec![],
154        };
155        let result = validator.validate(&filter);
156        assert!(result.is_err(), "empty scopes should be rejected");
157    }
158
159    #[test]
160    fn preserves_scopes() {
161        let validator = PrivateEventFilterValidator::new(10);
162        let mut filter = make_filter(None, None);
163        filter.scopes = vec![AztecAddress::from(42u64)];
164        let result = validator.validate(&filter).unwrap();
165        assert_eq!(result.scopes.len(), 1);
166    }
167
168    #[test]
169    fn to_query_filter_unchecked_passes_through() {
170        let filter = PrivateEventFilter {
171            contract_address: AztecAddress::from(1u64),
172            from_block: Some(3),
173            to_block: Some(7),
174            tx_hash: None,
175            after_log: None,
176            scopes: vec![AztecAddress::from(2u64)],
177        };
178        let query = to_query_filter_unchecked(&filter);
179        assert_eq!(query.from_block, Some(3));
180        assert_eq!(query.to_block, Some(7));
181        assert_eq!(query.scopes.len(), 1);
182    }
183}