Skip to main content

tap_msg/
settlement_address.rs

1//! Settlement address types supporting both blockchain (CAIP-10) and traditional payment systems (RFC 8905).
2//!
3//! This module provides types for handling settlement addresses that can be either
4//! blockchain addresses (CAIP-10 format) or traditional payment system identifiers
5//! (PayTo URI format per RFC 8905).
6
7use serde::{de, Deserialize, Deserializer, Serialize};
8use std::fmt;
9use std::str::FromStr;
10use thiserror::Error;
11
12/// Errors that can occur when parsing settlement addresses.
13#[derive(Debug, Error)]
14pub enum SettlementAddressError {
15    /// Invalid PayTo URI format.
16    #[error("Invalid PayTo URI format: {0}")]
17    InvalidPayToUri(String),
18
19    /// Invalid CAIP-10 format.
20    #[error("Invalid CAIP-10 format: {0}")]
21    InvalidCaip10(String),
22
23    /// Unknown settlement address format.
24    #[error("Unknown settlement address format")]
25    UnknownFormat,
26}
27
28/// A PayTo URI per RFC 8905 for traditional payment systems.
29///
30/// Format: `payto://METHOD/ACCOUNT[?parameters]`
31///
32/// Examples:
33/// - `payto://iban/DE75512108001245126199`
34/// - `payto://ach/122000247/111000025`
35/// - `payto://bic/SOGEDEFFXXX`
36/// - `payto://upi/9999999999@paytm`
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38#[serde(transparent)]
39pub struct PayToUri(String);
40
41impl<'de> Deserialize<'de> for PayToUri {
42    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
43    where
44        D: Deserializer<'de>,
45    {
46        let s = String::deserialize(deserializer)?;
47        PayToUri::new(s).map_err(de::Error::custom)
48    }
49}
50
51impl PayToUri {
52    /// Create a new PayTo URI, validating the format.
53    pub fn new(uri: String) -> Result<Self, SettlementAddressError> {
54        if !uri.starts_with("payto://") {
55            return Err(SettlementAddressError::InvalidPayToUri(
56                "PayTo URI must start with 'payto://'".to_string(),
57            ));
58        }
59
60        // Basic validation: must have method and account parts
61        let after_scheme = &uri[8..]; // Skip "payto://"
62        if !after_scheme.contains('/') {
63            return Err(SettlementAddressError::InvalidPayToUri(
64                "PayTo URI must have method and account parts".to_string(),
65            ));
66        }
67
68        let parts: Vec<&str> = after_scheme.splitn(2, '/').collect();
69        if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
70            return Err(SettlementAddressError::InvalidPayToUri(
71                "PayTo URI must have non-empty method and account".to_string(),
72            ));
73        }
74
75        Ok(PayToUri(uri))
76    }
77
78    /// Get the payment method (e.g., "iban", "ach", "bic", "upi").
79    pub fn method(&self) -> &str {
80        let after_scheme = &self.0[8..];
81        after_scheme.split('/').next().unwrap_or("")
82    }
83
84    /// Get the full URI as a string.
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88}
89
90impl fmt::Display for PayToUri {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write!(f, "{}", self.0)
93    }
94}
95
96impl FromStr for PayToUri {
97    type Err = SettlementAddressError;
98
99    fn from_str(s: &str) -> Result<Self, Self::Err> {
100        PayToUri::new(s.to_string())
101    }
102}
103
104/// A settlement address that can be either a blockchain address (CAIP-10) or
105/// a traditional payment system identifier (PayTo URI).
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum SettlementAddress {
108    /// A blockchain address in CAIP-10 format.
109    Caip10(String),
110
111    /// A traditional payment system identifier as a PayTo URI.
112    PayTo(PayToUri),
113}
114
115impl SettlementAddress {
116    /// Create a settlement address from a string, auto-detecting the format.
117    pub fn from_string(s: String) -> Result<Self, SettlementAddressError> {
118        if s.starts_with("payto://") {
119            Ok(SettlementAddress::PayTo(PayToUri::new(s)?))
120        } else if s.contains(':') {
121            // Basic CAIP-10 validation - should have at least chain_id:address format
122            let parts: Vec<&str> = s.split(':').collect();
123            if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
124                Ok(SettlementAddress::Caip10(s))
125            } else {
126                Err(SettlementAddressError::InvalidCaip10(
127                    "CAIP-10 must have chain_id and address parts".to_string(),
128                ))
129            }
130        } else {
131            Err(SettlementAddressError::UnknownFormat)
132        }
133    }
134
135    /// Check if this is a blockchain address.
136    pub fn is_blockchain(&self) -> bool {
137        matches!(self, SettlementAddress::Caip10(_))
138    }
139
140    /// Check if this is a traditional payment address.
141    pub fn is_traditional(&self) -> bool {
142        matches!(self, SettlementAddress::PayTo(_))
143    }
144
145    /// Get the address as a string.
146    pub fn as_str(&self) -> &str {
147        match self {
148            SettlementAddress::Caip10(s) => s,
149            SettlementAddress::PayTo(uri) => uri.as_str(),
150        }
151    }
152}
153
154impl fmt::Display for SettlementAddress {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "{}", self.as_str())
157    }
158}
159
160impl Serialize for SettlementAddress {
161    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
162    where
163        S: serde::Serializer,
164    {
165        serializer.serialize_str(self.as_str())
166    }
167}
168
169impl<'de> Deserialize<'de> for SettlementAddress {
170    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171    where
172        D: serde::Deserializer<'de>,
173    {
174        let s = String::deserialize(deserializer)?;
175        SettlementAddress::from_string(s).map_err(serde::de::Error::custom)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_payto_uri_creation() {
185        let uri = PayToUri::new("payto://iban/DE75512108001245126199".to_string()).unwrap();
186        assert_eq!(uri.method(), "iban");
187        assert_eq!(uri.as_str(), "payto://iban/DE75512108001245126199");
188    }
189
190    #[test]
191    fn test_payto_uri_with_parameters() {
192        let uri = PayToUri::new(
193            "payto://iban/GB33BUKB20201555555555?receiver-name=UK%20Receiver%20Ltd".to_string(),
194        )
195        .unwrap();
196        assert_eq!(uri.method(), "iban");
197        assert!(uri.as_str().contains("receiver-name"));
198    }
199
200    #[test]
201    fn test_payto_uri_various_methods() {
202        let test_cases = vec![
203            "payto://iban/DE75512108001245126199",
204            "payto://ach/122000247/111000025",
205            "payto://bic/SOGEDEFFXXX",
206            "payto://upi/9999999999@paytm",
207        ];
208
209        for case in test_cases {
210            let uri = PayToUri::new(case.to_string()).unwrap();
211            assert!(uri.as_str().starts_with("payto://"));
212        }
213    }
214
215    #[test]
216    fn test_payto_uri_invalid_format() {
217        let invalid_cases = vec![
218            "http://example.com",          // Wrong scheme
219            "payto://",                    // Missing method and account
220            "payto://iban",                // Missing account
221            "payto://iban/",               // Empty account
222            "iban/DE75512108001245126199", // Missing scheme
223        ];
224
225        for case in invalid_cases {
226            assert!(PayToUri::new(case.to_string()).is_err());
227        }
228    }
229
230    #[test]
231    fn test_settlement_address_from_payto() {
232        let addr =
233            SettlementAddress::from_string("payto://iban/DE75512108001245126199".to_string())
234                .unwrap();
235
236        assert!(addr.is_traditional());
237        assert!(!addr.is_blockchain());
238        assert_eq!(addr.as_str(), "payto://iban/DE75512108001245126199");
239    }
240
241    #[test]
242    fn test_settlement_address_from_caip10() {
243        let addr = SettlementAddress::from_string(
244            "eip155:1:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb".to_string(),
245        )
246        .unwrap();
247
248        assert!(addr.is_blockchain());
249        assert!(!addr.is_traditional());
250        assert_eq!(
251            addr.as_str(),
252            "eip155:1:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"
253        );
254    }
255
256    #[test]
257    fn test_settlement_address_simple_caip10() {
258        // Simple chain_id:address format
259        let addr = SettlementAddress::from_string(
260            "ethereum:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb".to_string(),
261        )
262        .unwrap();
263
264        assert!(addr.is_blockchain());
265        assert_eq!(
266            addr.as_str(),
267            "ethereum:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"
268        );
269    }
270
271    #[test]
272    fn test_settlement_address_invalid() {
273        let invalid_cases = vec![
274            "just-some-text", // No clear format
275            "",               // Empty string
276            ":",              // Just separator
277            "payto://",       // Invalid PayTo
278        ];
279
280        for case in invalid_cases {
281            assert!(SettlementAddress::from_string(case.to_string()).is_err());
282        }
283    }
284
285    #[test]
286    fn test_settlement_address_serialization() {
287        let payto_addr =
288            SettlementAddress::from_string("payto://iban/DE75512108001245126199".to_string())
289                .unwrap();
290
291        let json = serde_json::to_string(&payto_addr).unwrap();
292        assert_eq!(json, "\"payto://iban/DE75512108001245126199\"");
293
294        let deserialized: SettlementAddress = serde_json::from_str(&json).unwrap();
295        assert_eq!(deserialized, payto_addr);
296    }
297
298    #[test]
299    fn test_settlement_address_caip10_serialization() {
300        let caip_addr = SettlementAddress::from_string(
301            "eip155:1:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb".to_string(),
302        )
303        .unwrap();
304
305        let json = serde_json::to_string(&caip_addr).unwrap();
306        assert_eq!(
307            json,
308            "\"eip155:1:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb\""
309        );
310
311        let deserialized: SettlementAddress = serde_json::from_str(&json).unwrap();
312        assert_eq!(deserialized, caip_addr);
313    }
314
315    #[test]
316    fn test_settlement_address_array_serialization() {
317        let addresses = vec![
318            SettlementAddress::from_string("payto://iban/DE75512108001245126199".to_string())
319                .unwrap(),
320            SettlementAddress::from_string(
321                "eip155:1:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb".to_string(),
322            )
323            .unwrap(),
324        ];
325
326        let json = serde_json::to_string(&addresses).unwrap();
327        assert!(json.contains("payto://iban"));
328        assert!(json.contains("eip155:1"));
329
330        let deserialized: Vec<SettlementAddress> = serde_json::from_str(&json).unwrap();
331        assert_eq!(deserialized.len(), 2);
332        assert!(deserialized[0].is_traditional());
333        assert!(deserialized[1].is_blockchain());
334    }
335}