Skip to main content

mpp_br/protocol/intents/
session.rs

1//! Session intent request type.
2//!
3//! The session intent represents a pay-as-you-go session payment request.
4//! This module provides the `SessionRequest` type with string-only fields -
5//! no typed helpers like `amount_u256()`. Those are provided by the methods layer.
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::{MppError, Result};
10
11/// Session request (for session intent).
12///
13/// Represents a pay-as-you-go session payment request. All fields are strings
14/// to remain method-agnostic. Use the methods layer for typed accessors.
15///
16/// # Examples
17///
18/// ```
19/// use mpp::protocol::intents::SessionRequest;
20///
21/// let req = SessionRequest {
22///     amount: "1000".to_string(),
23///     unit_type: Some("second".to_string()),
24///     currency: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
25///     decimals: None,
26///     recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".to_string()),
27///     suggested_deposit: Some("60000".to_string()),
28///     method_details: None,
29/// };
30///
31/// assert_eq!(req.amount, "1000");
32/// assert_eq!(req.unit_type, Some("second".to_string()));
33/// ```
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct SessionRequest {
36    /// Amount per unit in base units (e.g., wei per second)
37    pub amount: String,
38
39    /// Unit type for the session rate (e.g., "second", "minute", "request"). Optional.
40    #[serde(rename = "unitType", skip_serializing_if = "Option::is_none")]
41    pub unit_type: Option<String>,
42
43    /// Currency/asset identifier (token address, ISO 4217 code, or symbol)
44    pub currency: String,
45
46    /// Token decimals for amount conversion (e.g., 6 for pathUSD).
47    ///
48    /// When set, `amount` and `suggested_deposit` are treated as human-readable
49    /// values and will be scaled by `10^decimals` during challenge creation.
50    /// The field is stripped from wire serialization.
51    #[serde(skip)]
52    pub decimals: Option<u8>,
53
54    /// Recipient address (optional, server may be recipient)
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub recipient: Option<String>,
57
58    /// Suggested deposit amount in base units
59    #[serde(rename = "suggestedDeposit", skip_serializing_if = "Option::is_none")]
60    pub suggested_deposit: Option<String>,
61
62    /// Method-specific extension fields (interpreted by methods layer)
63    #[serde(rename = "methodDetails", skip_serializing_if = "Option::is_none")]
64    pub method_details: Option<serde_json::Value>,
65}
66
67impl SessionRequest {
68    /// Apply the decimals transform, converting human-readable amounts to base units.
69    ///
70    /// Transforms both `amount` and `suggested_deposit` (if present).
71    /// If `decimals` is `None`, returns `self` unchanged.
72    pub fn with_base_units(mut self) -> Result<Self> {
73        if let Some(decimals) = self.decimals {
74            self.amount = super::parse_units(&self.amount, decimals)?;
75            if let Some(ref deposit) = self.suggested_deposit {
76                self.suggested_deposit = Some(super::parse_units(deposit, decimals)?);
77            }
78            self.decimals = None;
79        }
80        Ok(self)
81    }
82
83    /// Parse the amount as u128.
84    ///
85    /// Returns an error if the amount is not a valid unsigned integer.
86    pub fn parse_amount(&self) -> Result<u128> {
87        self.amount
88            .parse()
89            .map_err(|_| MppError::InvalidAmount(format!("Invalid amount: {}", self.amount)))
90    }
91
92    /// Validate that the session amount does not exceed a maximum.
93    ///
94    /// # Arguments
95    /// * `max_amount` - Maximum allowed amount as a string (atomic units)
96    ///
97    /// # Returns
98    /// * `Ok(())` if amount is within limit
99    /// * `Err(AmountExceedsMax)` if amount exceeds the maximum
100    pub fn validate_max_amount(&self, max_amount: &str) -> Result<()> {
101        let amount = self.parse_amount()?;
102        let max: u128 = max_amount
103            .parse()
104            .map_err(|_| MppError::InvalidAmount(format!("Invalid max amount: {}", max_amount)))?;
105
106        if amount > max {
107            return Err(MppError::AmountExceedsMax {
108                required: amount,
109                max,
110            });
111        }
112        Ok(())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_session_request_serialization() {
122        let req = SessionRequest {
123            amount: "1000".to_string(),
124            unit_type: Some("second".to_string()),
125            currency: "0x123".to_string(),
126            recipient: Some("0x456".to_string()),
127            suggested_deposit: Some("60000".to_string()),
128            method_details: Some(serde_json::json!({
129                "chainId": 42431,
130                "feePayer": true
131            })),
132            ..Default::default()
133        };
134
135        let json = serde_json::to_string(&req).unwrap();
136        assert!(json.contains("\"amount\":\"1000\""));
137        assert!(json.contains("\"unitType\":\"second\""));
138        assert!(json.contains("\"suggestedDeposit\":\"60000\""));
139        assert!(json.contains("\"methodDetails\""));
140        assert!(json.contains("\"chainId\":42431"));
141
142        let parsed: SessionRequest = serde_json::from_str(&json).unwrap();
143        assert_eq!(parsed.amount, "1000");
144        assert_eq!(parsed.unit_type, Some("second".to_string()));
145        assert_eq!(parsed.suggested_deposit.as_deref(), Some("60000"));
146    }
147
148    #[test]
149    fn test_session_request_optional_fields_omitted() {
150        let req = SessionRequest {
151            amount: "500".to_string(),
152            unit_type: Some("request".to_string()),
153            currency: "USD".to_string(),
154            ..Default::default()
155        };
156
157        let json = serde_json::to_string(&req).unwrap();
158        assert!(!json.contains("recipient"));
159        assert!(!json.contains("suggestedDeposit"));
160        assert!(!json.contains("methodDetails"));
161    }
162
163    #[test]
164    fn test_session_request_deserialization() {
165        let json = r#"{"amount":"2000","unitType":"minute","currency":"0xabc"}"#;
166        let parsed: SessionRequest = serde_json::from_str(json).unwrap();
167        assert_eq!(parsed.amount, "2000");
168        assert_eq!(parsed.unit_type, Some("minute".to_string()));
169        assert_eq!(parsed.currency, "0xabc");
170        assert!(parsed.recipient.is_none());
171        assert!(parsed.suggested_deposit.is_none());
172        assert!(parsed.method_details.is_none());
173    }
174
175    #[test]
176    fn test_parse_amount() {
177        let req = SessionRequest {
178            amount: "1000000".to_string(),
179            ..Default::default()
180        };
181        assert_eq!(req.parse_amount().unwrap(), 1_000_000u128);
182
183        let invalid = SessionRequest {
184            amount: "not-a-number".to_string(),
185            ..Default::default()
186        };
187        assert!(invalid.parse_amount().is_err());
188    }
189
190    #[test]
191    fn test_validate_max_amount() {
192        let req = SessionRequest {
193            amount: "1000".to_string(),
194            ..Default::default()
195        };
196
197        assert!(req.validate_max_amount("2000").is_ok());
198        assert!(req.validate_max_amount("1000").is_ok());
199        assert!(req.validate_max_amount("500").is_err());
200    }
201
202    #[test]
203    fn test_with_base_units() {
204        let req = SessionRequest {
205            amount: "1.5".to_string(),
206            unit_type: Some("second".to_string()),
207            currency: "0x123".to_string(),
208            decimals: Some(6),
209            suggested_deposit: Some("60".to_string()),
210            ..Default::default()
211        };
212        let converted = req.with_base_units().unwrap();
213        assert_eq!(converted.amount, "1500000");
214        assert_eq!(converted.suggested_deposit.as_deref(), Some("60000000"));
215        assert!(converted.decimals.is_none());
216    }
217
218    #[test]
219    fn test_with_base_units_no_decimals() {
220        let req = SessionRequest {
221            amount: "1000000".to_string(),
222            unit_type: Some("second".to_string()),
223            currency: "0x123".to_string(),
224            ..Default::default()
225        };
226        let converted = req.with_base_units().unwrap();
227        assert_eq!(converted.amount, "1000000");
228    }
229
230    #[test]
231    fn test_session_request_without_unit_type() {
232        let json = r#"{"amount":"2000","currency":"0xabc"}"#;
233        let parsed: SessionRequest = serde_json::from_str(json).unwrap();
234        assert_eq!(parsed.amount, "2000");
235        assert!(parsed.unit_type.is_none());
236    }
237}