Skip to main content

tap_msg/message/
rfq.rs

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