Skip to main content

tap_msg/message/
exchange.rs

1//! Exchange and Quote message implementations for the Transaction Authorization Protocol.
2//!
3//! This module defines the Exchange and Quote message types (TAIP-18) for requesting
4//! and executing asset exchanges and price quotations.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::error::{Error, Result};
10use crate::message::agent::TapParticipant;
11use crate::message::tap_message_trait::{TapMessage as TapMessageTrait, TapMessageBody};
12use crate::message::{Agent, Party};
13use crate::TapMessage;
14
15/// Exchange message body (TAIP-18).
16///
17/// Initiates an exchange request for cross-asset quotes. Supports multiple source
18/// and target assets, enabling complex exchange scenarios like cross-currency swaps,
19/// on/off-ramp pricing, and cross-chain bridging.
20#[derive(Debug, Clone, Serialize, Deserialize, TapMessage)]
21#[tap(
22    message_type = "https://tap.rsvp/schema/1.0#Exchange",
23    initiator,
24    authorizable,
25    transactable
26)]
27pub struct Exchange {
28    /// Available source assets (CAIP-19, DTI, or ISO 4217 currency codes).
29    #[serde(rename = "fromAssets")]
30    pub from_assets: Vec<String>,
31
32    /// Desired target assets (CAIP-19, DTI, or ISO 4217 currency codes).
33    #[serde(rename = "toAssets")]
34    pub to_assets: Vec<String>,
35
36    /// Amount of source asset to exchange (conditional: either this or to_amount required).
37    #[serde(rename = "fromAmount", skip_serializing_if = "Option::is_none")]
38    pub from_amount: Option<String>,
39
40    /// Amount of target asset desired (conditional: either this or from_amount required).
41    #[serde(rename = "toAmount", skip_serializing_if = "Option::is_none")]
42    pub to_amount: Option<String>,
43
44    /// The party requesting the exchange.
45    #[tap(participant)]
46    pub requester: Party,
47
48    /// The preferred liquidity provider (optional, omit to broadcast).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    #[tap(participant)]
51    pub provider: Option<Party>,
52
53    /// Agents involved in the exchange request.
54    #[serde(default)]
55    #[tap(participant_list)]
56    pub agents: Vec<Agent>,
57
58    /// Compliance or presentation requirements (TAIP-7).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub policies: Option<Vec<serde_json::Value>>,
61
62    /// Transaction identifier (only available after creation).
63    #[serde(skip)]
64    #[tap(transaction_id)]
65    pub transaction_id: Option<String>,
66
67    /// Additional metadata.
68    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69    pub metadata: HashMap<String, serde_json::Value>,
70}
71
72impl Exchange {
73    /// Create a new Exchange with from_amount specified.
74    pub fn new_from(
75        from_assets: Vec<String>,
76        to_assets: Vec<String>,
77        from_amount: String,
78        requester: Party,
79        agents: Vec<Agent>,
80    ) -> Self {
81        Self {
82            from_assets,
83            to_assets,
84            from_amount: Some(from_amount),
85            to_amount: None,
86            requester,
87            provider: None,
88            agents,
89            policies: None,
90            transaction_id: None,
91            metadata: HashMap::new(),
92        }
93    }
94
95    /// Create a new Exchange with to_amount specified.
96    pub fn new_to(
97        from_assets: Vec<String>,
98        to_assets: Vec<String>,
99        to_amount: String,
100        requester: Party,
101        agents: Vec<Agent>,
102    ) -> Self {
103        Self {
104            from_assets,
105            to_assets,
106            from_amount: None,
107            to_amount: Some(to_amount),
108            requester,
109            provider: None,
110            agents,
111            policies: None,
112            transaction_id: None,
113            metadata: HashMap::new(),
114        }
115    }
116
117    /// Set the provider for this exchange.
118    pub fn with_provider(mut self, provider: Party) -> Self {
119        self.provider = Some(provider);
120        self
121    }
122
123    /// Set policies for this exchange.
124    pub fn with_policies(mut self, policies: Vec<serde_json::Value>) -> Self {
125        self.policies = Some(policies);
126        self
127    }
128
129    /// Custom validation for Exchange messages.
130    pub fn validate(&self) -> Result<()> {
131        if self.from_assets.is_empty() {
132            return Err(Error::Validation(
133                "fromAssets must not be empty".to_string(),
134            ));
135        }
136        if self.to_assets.is_empty() {
137            return Err(Error::Validation("toAssets must not be empty".to_string()));
138        }
139        if self.from_amount.is_none() && self.to_amount.is_none() {
140            return Err(Error::Validation(
141                "Either fromAmount or toAmount must be provided".to_string(),
142            ));
143        }
144        if self.requester.id().is_empty() {
145            return Err(Error::Validation(
146                "Requester ID cannot be empty".to_string(),
147            ));
148        }
149        Ok(())
150    }
151}
152
153/// Quote message body (TAIP-18).
154///
155/// Sent by a liquidity provider in response to an Exchange request.
156/// Specifies a specific asset pair with amounts and an expiration time.
157#[derive(Debug, Clone, Serialize, Deserialize, TapMessage)]
158#[tap(message_type = "https://tap.rsvp/schema/1.0#Quote")]
159pub struct Quote {
160    /// Source asset (CAIP-19, DTI, or ISO 4217 currency code).
161    #[serde(rename = "fromAsset")]
162    pub from_asset: String,
163
164    /// Target asset (CAIP-19, DTI, or ISO 4217 currency code).
165    #[serde(rename = "toAsset")]
166    pub to_asset: String,
167
168    /// Amount of source asset to be exchanged.
169    #[serde(rename = "fromAmount")]
170    pub from_amount: String,
171
172    /// Amount of target asset to be received.
173    #[serde(rename = "toAmount")]
174    pub to_amount: String,
175
176    /// The liquidity provider party.
177    #[tap(participant)]
178    pub provider: Party,
179
180    /// All agents involved (original Exchange agents + provider agents).
181    #[serde(default)]
182    #[tap(participant_list)]
183    pub agents: Vec<Agent>,
184
185    /// ISO 8601 timestamp when the quote expires.
186    pub expires: String,
187
188    /// Additional metadata.
189    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
190    pub metadata: HashMap<String, serde_json::Value>,
191}
192
193impl Quote {
194    /// Create a new Quote.
195    pub fn new(
196        from_asset: String,
197        to_asset: String,
198        from_amount: String,
199        to_amount: String,
200        provider: Party,
201        agents: Vec<Agent>,
202        expires: String,
203    ) -> Self {
204        Self {
205            from_asset,
206            to_asset,
207            from_amount,
208            to_amount,
209            provider,
210            agents,
211            expires,
212            metadata: HashMap::new(),
213        }
214    }
215
216    /// Custom validation for Quote messages.
217    pub fn validate(&self) -> Result<()> {
218        if self.from_asset.is_empty() {
219            return Err(Error::Validation("fromAsset must not be empty".to_string()));
220        }
221        if self.to_asset.is_empty() {
222            return Err(Error::Validation("toAsset must not be empty".to_string()));
223        }
224        if self.from_amount.is_empty() {
225            return Err(Error::Validation(
226                "fromAmount must not be empty".to_string(),
227            ));
228        }
229        if self.to_amount.is_empty() {
230            return Err(Error::Validation("toAmount must not be empty".to_string()));
231        }
232        if self.provider.id().is_empty() {
233            return Err(Error::Validation("Provider ID cannot be empty".to_string()));
234        }
235        if self.expires.is_empty() {
236            return Err(Error::Validation("expires must not be empty".to_string()));
237        }
238        Ok(())
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use serde_json;
246
247    #[test]
248    fn test_exchange_creation() {
249        let exchange = Exchange::new_from(
250            vec!["eip155:1/erc20:0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()],
251            vec!["eip155:1/erc20:0xB00b00b00b00b00b00b00b00b00b00b00b00b00b".to_string()],
252            "1000.00".to_string(),
253            Party::new("did:web:business.example"),
254            vec![Agent::new_without_role(
255                "did:web:wallet.example",
256                "did:web:business.example",
257            )],
258        )
259        .with_provider(Party::new("did:web:liquidity.provider"));
260
261        assert_eq!(exchange.from_assets.len(), 1);
262        assert_eq!(exchange.to_assets.len(), 1);
263        assert_eq!(exchange.from_amount, Some("1000.00".to_string()));
264        assert!(exchange.to_amount.is_none());
265        assert!(exchange.provider.is_some());
266        assert!(exchange.validate().is_ok());
267    }
268
269    #[test]
270    fn test_exchange_serialization() {
271        let exchange = Exchange::new_from(
272            vec!["USD".to_string()],
273            vec!["eip155:1/erc20:0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()],
274            "1000.00".to_string(),
275            Party::new("did:web:user.entity"),
276            vec![Agent::new_without_role(
277                "did:web:user.wallet",
278                "did:web:user.entity",
279            )],
280        );
281
282        let json = serde_json::to_value(&exchange).unwrap();
283        assert_eq!(json["fromAssets"][0], "USD");
284        assert_eq!(json["fromAmount"], "1000.00");
285        assert!(json.get("toAmount").is_none());
286
287        let deserialized: Exchange = serde_json::from_value(json).unwrap();
288        assert_eq!(deserialized.from_assets, exchange.from_assets);
289    }
290
291    #[test]
292    fn test_exchange_validation_no_amount() {
293        let exchange = Exchange {
294            from_assets: vec!["USD".to_string()],
295            to_assets: vec!["EUR".to_string()],
296            from_amount: None,
297            to_amount: None,
298            requester: Party::new("did:example:user"),
299            provider: None,
300            agents: vec![],
301            policies: None,
302            transaction_id: None,
303            metadata: HashMap::new(),
304        };
305
306        let result = exchange.validate();
307        assert!(result.is_err());
308        assert!(result
309            .unwrap_err()
310            .to_string()
311            .contains("Either fromAmount or toAmount"));
312    }
313
314    #[test]
315    fn test_quote_creation() {
316        let quote = Quote::new(
317            "eip155:1/erc20:0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(),
318            "eip155:1/erc20:0xB00b00b00b00b00b00b00b00b00b00b00b00b00b".to_string(),
319            "1000.00".to_string(),
320            "908.50".to_string(),
321            Party::new("did:web:liquidity.provider"),
322            vec![
323                Agent::new_without_role("did:web:wallet.example", "did:web:business.example"),
324                Agent::new_without_role("did:web:lp.example", "did:web:liquidity.provider"),
325            ],
326            "2025-07-21T00:00:00Z".to_string(),
327        );
328
329        assert_eq!(quote.from_amount, "1000.00");
330        assert_eq!(quote.to_amount, "908.50");
331        assert_eq!(quote.agents.len(), 2);
332        assert!(quote.validate().is_ok());
333    }
334
335    #[test]
336    fn test_quote_serialization() {
337        let quote = Quote::new(
338            "USD".to_string(),
339            "eip155:1/erc20:0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(),
340            "1000.00".to_string(),
341            "996.00".to_string(),
342            Party::new("did:web:onramp.company"),
343            vec![],
344            "2025-07-21T00:00:00Z".to_string(),
345        );
346
347        let json = serde_json::to_value(&quote).unwrap();
348        assert_eq!(json["fromAsset"], "USD");
349        assert_eq!(json["toAmount"], "996.00");
350        assert_eq!(json["expires"], "2025-07-21T00:00:00Z");
351
352        let deserialized: Quote = serde_json::from_value(json).unwrap();
353        assert_eq!(deserialized.from_amount, quote.from_amount);
354    }
355}