Skip to main content

mostro_core/
user.rs

1//! Persistent user representation and reputation helpers.
2//!
3//! The [`User`] struct is the database-backed record Mostro keeps for every
4//! identity that has interacted with the system. It tracks rating aggregates,
5//! administrative flags and the last trade index used by the user, which is
6//! required so that new orders always carry a strictly increasing trade index.
7//!
8//! [`UserInfo`] is a lightweight view of the same data that can safely be
9//! shared with a counterpart during a trade without leaking internals.
10
11use chrono::Utc;
12use serde::{Deserialize, Serialize, Serializer};
13#[cfg(feature = "sqlx")]
14use sqlx::FromRow;
15
16/// Seconds in a day, the granularity every published first-trade date uses.
17const SECONDS_PER_DAY: u64 = 86_400;
18
19/// Truncate a Unix timestamp to the start of its UTC day.
20///
21/// Every published first-trade date goes through this. The rounding is not
22/// cosmetic: the value travels on every order and every peer message of the
23/// same user, so a second-precision timestamp would be a fingerprint that ties
24/// their otherwise unlinkable trade pubkeys together. A day carries exactly
25/// the information the old `operating_days` count carried.
26///
27/// Timestamps before the epoch clamp to `0` rather than wrap.
28pub fn day_truncate(timestamp: i64) -> u64 {
29    truncate_since(timestamp.max(0) as u64)
30}
31
32/// [`day_truncate`] for a value that is already a `since`. Idempotent, so it
33/// is safe on every path a `since` takes out of the crate.
34pub(crate) fn truncate_since(since: u64) -> u64 {
35    since - since % SECONDS_PER_DAY
36}
37
38/// `serialize_with` for a `since` field: the field is public, so rounding at
39/// the serialization boundary is the only place that covers every producer.
40pub(crate) fn serialize_since<S: Serializer>(
41    since: &Option<u64>,
42    serializer: S,
43) -> Result<S::Ok, S::Error> {
44    since.map(truncate_since).serialize(serializer)
45}
46
47/// Public snapshot of a user's reputation shared with peers during a trade.
48///
49/// Unlike [`User`], `UserInfo` contains only the values a counterpart needs
50/// to decide whether to trade: aggregated rating, number of reviews and how
51/// many days the user has been operating on Mostro.
52#[derive(Debug, Default, Deserialize, Serialize, Clone)]
53
54pub struct UserInfo {
55    /// Aggregated rating value for the user (see [`crate::rating::Rating`]).
56    pub rating: f64,
57    /// Total number of ratings received.
58    pub reviews: i64,
59    /// Number of days since the user account was created.
60    ///
61    /// Superseded by [`UserInfo::since`], which carries the same information
62    /// as a date instead of a count derived when the message was built. Kept
63    /// for one deprecation window so old and new peers interoperate.
64    pub operating_days: u64,
65    /// Unix timestamp of the user's first trade, truncated to the start of its
66    /// UTC day, or `None` from a peer that does not send it yet.
67    ///
68    /// Clients compute the age at display time, so it does not go stale the
69    /// way `operating_days` does. Skipped when absent, so a `UserInfo` that
70    /// never sets it serializes exactly as it did before this field existed.
71    /// Always serialized truncated to its UTC day, however it was set.
72    #[serde(
73        default,
74        skip_serializing_if = "Option::is_none",
75        serialize_with = "serialize_since"
76    )]
77    pub since: Option<u64>,
78}
79
80/// Database representation of a Mostro user.
81///
82/// This is the canonical row stored on the Mostro node. It tracks identity
83/// data (`pubkey`), administrative role flags, the last trade index used by
84/// the user and rating aggregates used to compute reputation.
85#[cfg_attr(feature = "sqlx", derive(FromRow))]
86#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
87pub struct User {
88    /// Master public key of the user, hex encoded.
89    pub pubkey: String,
90    /// `1` when the user has admin privileges, `0` otherwise. Stored as
91    /// `i64` to match the underlying SQLite representation.
92    pub is_admin: i64,
93    /// Optional password used to authenticate privileged admin actions.
94    pub admin_password: Option<String>,
95    /// `1` when the user is a dispute solver, `0` otherwise.
96    pub is_solver: i64,
97    /// `1` when the user is banned from the platform, `0` otherwise.
98    pub is_banned: i64,
99    /// Free-form category bucket. Reserved for future segmentation.
100    pub category: i64,
101    /// Last trade index used by this user. When a user creates a new order
102    /// (or takes one) the incoming trade index must be strictly greater than
103    /// this value, or the request is rejected.
104    pub last_trade_index: i64,
105    /// Total number of ratings the user has received.
106    pub total_reviews: i64,
107    /// Weighted rating average computed from all received ratings.
108    pub total_rating: f64,
109    /// Most recent rating received, in the `MIN_RATING..=MAX_RATING` range.
110    pub last_rating: i64,
111    /// Highest rating ever received.
112    pub max_rating: i64,
113    /// Lowest rating ever received.
114    pub min_rating: i64,
115    /// Unix timestamp (seconds) when the user record was created.
116    pub created_at: i64,
117}
118
119impl User {
120    /// Create a new [`User`] with fresh rating aggregates.
121    ///
122    /// `trade_index` becomes the user's `last_trade_index`. The `created_at`
123    /// timestamp is set to the current system time.
124    pub fn new(
125        pubkey: String,
126        is_admin: i64,
127        is_solver: i64,
128        is_banned: i64,
129        category: i64,
130        trade_index: i64,
131    ) -> Self {
132        Self {
133            pubkey,
134            is_admin,
135            admin_password: None,
136            is_solver,
137            is_banned,
138            category,
139            last_trade_index: trade_index,
140            total_reviews: 0,
141            total_rating: 0.0,
142            last_rating: 0,
143            max_rating: 0,
144            min_rating: 0,
145            created_at: Utc::now().timestamp(),
146        }
147    }
148
149    /// Record a new rating for the user and refresh the aggregates.
150    ///
151    /// The first vote is weighted by `1/2` so that a single review cannot
152    /// anchor a perfect or disastrous reputation. Subsequent votes update
153    /// `total_rating` with an incremental running-average formula.
154    /// `min_rating` and `max_rating` are tightened as new extremes arrive.
155    ///
156    /// # Example
157    ///
158    /// ```
159    /// use mostro_core::user::User;
160    ///
161    /// let mut user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
162    /// user.update_rating(5);
163    /// assert_eq!(user.total_reviews, 1);
164    /// assert_eq!(user.max_rating, 5);
165    /// ```
166    pub fn update_rating(&mut self, rating: u8) {
167        // Update user reputation
168        // increment first
169        self.total_reviews += 1;
170        let old_rating = self.total_rating;
171        // recompute new rating
172        if self.total_reviews <= 1 {
173            // New logic with weight 1/2 for first vote.
174            let first_rating = rating as f64;
175            self.total_rating = first_rating / 2.0;
176            self.max_rating = rating.into();
177            self.min_rating = rating.into();
178        } else {
179            self.total_rating =
180                old_rating + ((rating as f64) - old_rating) / (self.total_reviews as f64);
181            if self.max_rating < rating.into() {
182                self.max_rating = rating.into();
183            }
184            if self.min_rating > rating.into() {
185                self.min_rating = rating.into();
186            }
187        }
188        // Store last rating
189        self.last_rating = rating.into();
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    /// 2026-01-01T00:00:00Z, and the same instant plus most of a day.
198    const DAY_START: i64 = 1_767_225_600;
199    const LATE_IN_DAY: i64 = DAY_START + 86_399;
200
201    #[test]
202    fn day_truncate_lands_on_the_start_of_the_utc_day() {
203        // Arrange / Act / Assert — anywhere inside a day maps to its start, so
204        // the value cannot change while the user is mid-session.
205        assert_eq!(day_truncate(DAY_START), DAY_START as u64);
206        assert_eq!(day_truncate(LATE_IN_DAY), DAY_START as u64);
207        assert_eq!(
208            day_truncate(DAY_START + 86_400),
209            (DAY_START + 86_400) as u64
210        );
211    }
212
213    #[test]
214    fn day_truncate_clamps_a_pre_epoch_timestamp_instead_of_wrapping() {
215        // Arrange / Act / Assert — a negative cast to u64 would produce a date
216        // far in the future, which reads as a user who has not started trading.
217        assert_eq!(day_truncate(-1), 0);
218        assert_eq!(day_truncate(0), 0);
219    }
220
221    #[test]
222    fn user_info_omits_since_when_unset() {
223        // Arrange
224        let info = UserInfo {
225            rating: 4.5,
226            reviews: 10,
227            operating_days: 30,
228            since: None,
229        };
230
231        // Act
232        let json = serde_json::to_string(&info).expect("serializes");
233
234        // Assert — a peer that has not adopted the field must see the exact
235        // payload it saw before.
236        assert!(!json.contains("since"), "{json}");
237    }
238
239    #[test]
240    fn user_info_round_trips_with_since() {
241        // Arrange
242        let info = UserInfo {
243            rating: 4.5,
244            reviews: 10,
245            operating_days: 30,
246            since: Some(DAY_START as u64),
247        };
248
249        // Act
250        let parsed: UserInfo =
251            serde_json::from_str(&serde_json::to_string(&info).expect("serializes"))
252                .expect("parses");
253
254        // Assert
255        assert_eq!(parsed.since, Some(DAY_START as u64));
256        assert_eq!(parsed.operating_days, 30);
257    }
258
259    #[test]
260    fn user_info_truncates_since_on_serialization() {
261        // Arrange — built directly, bypassing any helper that would round it.
262        let info = UserInfo {
263            rating: 4.5,
264            reviews: 10,
265            operating_days: 30,
266            since: Some(LATE_IN_DAY as u64),
267        };
268
269        // Act
270        let json = serde_json::to_string(&info).expect("serializes");
271
272        // Assert — the peer sees the day, never the second.
273        assert!(json.contains(&format!("\"since\":{DAY_START}")), "{json}");
274    }
275
276    #[test]
277    fn user_info_from_a_peer_without_since_parses() {
278        // Arrange — what every daemon sends today.
279        let json = r#"{"rating":4.5,"reviews":10,"operating_days":30}"#;
280
281        // Act
282        let parsed: UserInfo = serde_json::from_str(json).expect("parses");
283
284        // Assert
285        assert_eq!(parsed.since, None);
286        assert_eq!(parsed.operating_days, 30);
287    }
288
289    #[test]
290    fn first_vote_is_weighted_by_half() {
291        let mut user = User::default();
292
293        user.update_rating(5);
294
295        assert_eq!(user.total_reviews, 1);
296        assert_eq!(user.total_rating, 2.5);
297        assert_eq!(user.last_rating, 5);
298        assert_eq!(user.max_rating, 5);
299        assert_eq!(user.min_rating, 5);
300    }
301
302    #[test]
303    fn second_vote_is_folded_into_the_average() {
304        let mut user = User::default();
305        user.update_rating(5);
306
307        user.update_rating(1);
308
309        // First vote weighted 1/2 -> 2.5, then incremental average with the
310        // new vote: 2.5 + (1 - 2.5) / 2 = 1.75
311        assert_eq!(user.total_reviews, 2);
312        assert!((user.total_rating - 1.75).abs() < 1e-9);
313        assert_eq!(user.last_rating, 1);
314    }
315
316    #[test]
317    fn low_vote_lowers_a_high_average() {
318        let mut user = User::default();
319        for _ in 0..10 {
320            user.update_rating(5);
321        }
322        let farmed_average = user.total_rating;
323        assert!((farmed_average - 4.75).abs() < 1e-9);
324
325        user.update_rating(1);
326
327        // Correct running average: (2.5 + 9 * 5 + 1) / 11 = 48.5 / 11
328        let expected = 48.5 / 11.0;
329        assert!(
330            user.total_rating < farmed_average,
331            "a 1-star review must lower the average, got {} (was {})",
332            user.total_rating,
333            farmed_average
334        );
335        assert!((user.total_rating - expected).abs() < 1e-9);
336        assert_eq!(user.last_rating, 1);
337        assert_eq!(user.min_rating, 1);
338        assert_eq!(user.max_rating, 5);
339    }
340
341    #[test]
342    fn high_vote_raises_a_low_average() {
343        let mut user = User::default();
344        user.update_rating(1);
345        user.update_rating(1);
346        let low_average = user.total_rating;
347
348        user.update_rating(5);
349
350        assert!(
351            user.total_rating > low_average,
352            "a 5-star review must raise the average, got {} (was {})",
353            user.total_rating,
354            low_average
355        );
356        // (0.5 + 1 + 5) / 3
357        assert!((user.total_rating - 6.5 / 3.0).abs() < 1e-9);
358    }
359
360    #[test]
361    fn min_and_max_track_extremes() {
362        let mut user = User::default();
363        user.update_rating(3);
364        user.update_rating(5);
365        user.update_rating(1);
366
367        assert_eq!(user.max_rating, 5);
368        assert_eq!(user.min_rating, 1);
369    }
370}