rostrum 14.0.1

An efficient implementation of Electrum Server with token support
Documentation
use std::fmt::Display;

use serde_json::Value;

use crate::{
    errors::{RpcError, RpcErrorCode},
    mempool::MEMPOOL_HEIGHT,
};

const FILTER_INCLUDE_TOKENS: &str = "include_tokens";
const FILTER_EXCLUDE_TOKENS: &str = "exclude_tokens";
const FILTER_TOKENS_ONLY: &str = "tokens_only";
const FILTER_FROM_HEIGHT: &str = "from_height";
const FILTER_TO_HEIGHT: &str = "to_height";
const FILTER_LIMIT: &str = "limit";
const FILTER_OFFSET: &str = "offset";

const NO_TO_FILTER: i64 = -1;
const NO_FROM_FILTER: i64 = 0;

const NO_LIMIT: i64 = 0;
const NO_OFFSET: i64 = 0;

#[derive(Debug, Clone, Copy)]
pub struct QueryFilter {
    /// If we should exclude outputs with tokens in them
    pub exclude_tokens: bool,
    /// If we should exclude outputs without tokens
    pub token_only: bool,
    /// Ignore entries before this height (inclusive)
    pub from_height: i64,
    /// Ignore entries after this height (exclusive)
    pub to_height: i64,
    // Skip the first n entries
    pub offset: i64,
    // Limit the result to n entries
    pub limit: i64,
}

impl QueryFilter {
    pub fn filter_token_only() -> Self {
        Self {
            token_only: true,
            ..Self::default()
        }
    }

    pub fn filter_exclude_tokens() -> Self {
        Self {
            exclude_tokens: true,
            ..Self::default()
        }
    }

    pub fn height_within<T: Into<i64>>(&self, height: T) -> bool {
        let height = height.into();
        let height = if height == MEMPOOL_HEIGHT as i64 {
            0
        } else {
            height
        };

        if height == 0 {
            // mempool case; we only care about to_height
            return self.to_height == NO_TO_FILTER;
        }

        debug_assert!(height != 0);

        // filter by from inclusive, to exclusive

        if self.to_height == NO_TO_FILTER {
            height >= self.from_height
        } else if self.from_height == NO_FROM_FILTER {
            height < self.to_height
        } else {
            height >= self.from_height && height < self.to_height
        }
    }

    pub fn from_param(param: Option<&Value>) -> Result<Self, RpcError> {
        Self::from_param_custom_default(param, QueryFilter::default())
    }

    pub fn from_param_custom_default(
        param: Option<&Value>,
        mut default: QueryFilter,
    ) -> Result<Self, RpcError> {
        if param.is_none() || param == Some(&Value::Null) {
            return Ok(default);
        }
        let param = param.unwrap();

        // Try to parse filter as a string
        match param.as_str() {
            Some(FILTER_INCLUDE_TOKENS) => return Ok(default),
            Some(FILTER_EXCLUDE_TOKENS) => {
                default.exclude_tokens = true;
                return Ok(default);
            }
            Some(FILTER_TOKENS_ONLY) => {
                default.token_only = true;
                return Ok(default);
            }
            Some(other_str) => {
                return Err(RpcError {
                    code: RpcErrorCode::InvalidParams,
                    msg: format!("Invalid parameter {} for filter", other_str),
                })
            }
            None => { /* it's a filter object, not a string, continue */ }
        };

        // Try to parse filter as an object
        let param = match param.as_object() {
            Some(o) => o,
            None => {
                return Err(RpcError {
                    code: RpcErrorCode::InvalidParams,
                    msg: "Invalid query filter parameter. Needs to be an object or string"
                        .to_string(),
                })
            }
        };

        let parse_bool = |what| -> Result<Option<bool>, RpcError> {
            match param.get(what) {
                Some(p) if p.is_null() => Ok(None),
                Some(p) => match p.as_bool() {
                    Some(b) => Ok(Some(b)),
                    None => Err(RpcError {
                        code: RpcErrorCode::InvalidParams,
                        msg: format!("Filter field {} must be a boolean", what),
                    }),
                },
                None => Ok(None),
            }
        };

        let parse_int = |what| -> Result<Option<i64>, RpcError> {
            match param.get(what) {
                Some(p) if p.is_null() => Ok(None),
                Some(p) => match p.as_i64() {
                    Some(i) => Ok(Some(i)),
                    None => Err(RpcError {
                        code: RpcErrorCode::InvalidParams,
                        msg: format!("Filter field {} must be an integer", what),
                    }),
                },
                None => Ok(None),
            }
        };

        if let Some(opt) = parse_bool(FILTER_TOKENS_ONLY)? {
            default.token_only = opt;
        }
        if let Some(opt) = parse_bool(FILTER_EXCLUDE_TOKENS)? {
            default.exclude_tokens = opt;
        }
        if let Some(opt) = parse_int(FILTER_FROM_HEIGHT)? {
            default.from_height = opt;
        }
        if let Some(opt) = parse_int(FILTER_TO_HEIGHT)? {
            default.to_height = opt;
        }
        if let Some(opt) = parse_int(FILTER_OFFSET)? {
            default.offset = opt;
        }
        if let Some(opt) = parse_int(FILTER_LIMIT)? {
            default.limit = opt
        }

        // input sanity checks
        if default.to_height != NO_TO_FILTER && default.from_height > default.to_height {
            return Err(RpcError {
                code: RpcErrorCode::InvalidParams,
                msg: format!(
                    "from_height cannot be larger than to_height ({} > {})",
                    default.from_height, default.to_height
                ),
            });
        }
        if default.exclude_tokens && default.token_only {
            return Err(RpcError {
                code: RpcErrorCode::InvalidParams,
                msg: "'exclude_tokens' and 'token_only' are mutually exclusive".to_string(),
            });
        }
        if default.from_height < 0 {
            return Err(RpcError {
                code: RpcErrorCode::InvalidParams,
                msg: "from_height cannot be negative".to_string(),
            });
        }
        if default.to_height < -1 {
            return Err(RpcError {
                code: RpcErrorCode::InvalidParams,
                msg: "to_height must be positive number or -1 (include mempool)".to_string(),
            });
        }
        if default.offset < 0 {
            return Err(RpcError {
                code: RpcErrorCode::InvalidParams,
                msg: "offset cannot be negative".to_string(),
            });
        }
        if default.limit < 0 {
            return Err(RpcError {
                code: RpcErrorCode::InvalidParams,
                msg: "limit cannot be negative".to_string(),
            });
        }

        Ok(default)
    }

    pub(crate) fn filter_limit_offset<T>(&self, results: &mut Vec<T>) {
        if self.offset != NO_OFFSET {
            let offset = std::cmp::min(self.offset as usize, results.len());
            let _ = results.drain(0..offset);
        }
        if self.limit != NO_LIMIT {
            results.truncate(self.limit as usize);
        }
    }

    pub(crate) fn has_height_filter(&self) -> bool {
        !(self.from_height == NO_FROM_FILTER && self.to_height == NO_TO_FILTER)
    }
}

impl Default for QueryFilter {
    fn default() -> Self {
        Self {
            exclude_tokens: false,
            token_only: false,
            from_height: NO_FROM_FILTER,
            to_height: NO_TO_FILTER,
            limit: NO_LIMIT,
            offset: NO_OFFSET,
        }
    }
}

impl Display for QueryFilter {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "QueryFilter {{ exclude_tokens: {}, token_only: {}, from_height: {}, to_height: {} }}",
            self.exclude_tokens, self.token_only, self.from_height, self.to_height
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_height_within_mempool_with_from_height_set() {
        let filter = QueryFilter {
            from_height: 5,
            to_height: NO_TO_FILTER,
            ..QueryFilter::default()
        };

        assert!(filter.height_within(0)); // Mempool height (0) should be included since to_height is NO_TO_FILTER
        assert!(filter.height_within(5)); // From height is inclusive
        assert!(filter.height_within(10)); // Should be included since to_height is NO_TO_FILTER
    }

    #[test]
    fn test_height_within_regular_range() {
        let filter = QueryFilter {
            from_height: 10,
            to_height: 20,
            ..QueryFilter::default()
        };

        assert!(filter.height_within(15)); // Within range
        assert!(filter.height_within(10)); // From height is inclusive
        assert!(!filter.height_within(20)); // To height is exclusive
        assert!(!filter.height_within(5)); // Below range
        assert!(!filter.height_within(25)); // Above range
    }

    #[test]
    fn test_height_within_no_to_filter() {
        let filter = QueryFilter {
            from_height: 10,
            to_height: NO_TO_FILTER,
            ..QueryFilter::default()
        };

        assert!(filter.height_within(10)); // From height is inclusive
        assert!(filter.height_within(15)); // No upper limit
        assert!(filter.height_within(100)); // Still within because to_height is not set
        assert!(!filter.height_within(5)); // Below range
    }

    #[test]
    fn test_height_within_mempool_case() {
        let filter = QueryFilter {
            from_height: 0,
            to_height: NO_TO_FILTER,
            ..QueryFilter::default()
        };

        assert!(filter.height_within(0)); // Mempool height (0) should be included when to_height is NO_TO_FILTER
    }

    #[test]
    fn test_from_param_custom_default_with_null() {
        let default = QueryFilter {
            limit: 100,
            ..QueryFilter::default()
        };
        let result = QueryFilter::from_param_custom_default(Some(&Value::Null), default.clone());
        assert!(result.is_ok());
        assert_eq!(result.unwrap().limit, 100);
    }

    #[test]
    fn test_from_param_custom_default_with_none() {
        let default = QueryFilter {
            limit: 100,
            ..QueryFilter::default()
        };
        let result = QueryFilter::from_param_custom_default(None, default.clone());
        assert!(result.is_ok());
        assert_eq!(result.unwrap().limit, 100);
    }
}