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}