Skip to main content

tap_msg/message/
escrow.rs

1//! Composable Escrow message types (TAIP-17)
2//!
3//! This module implements the Escrow and Capture message types for holding and releasing
4//! funds on behalf of parties, enabling payment guarantees and asset swaps.
5
6use crate::error::{Error, Result};
7use crate::message::agent::Agent;
8use crate::message::party::Party;
9use crate::message::tap_message_trait::TapMessageBody;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::collections::HashMap;
13
14/// Escrow message for holding assets on behalf of parties
15///
16/// The Escrow message allows one agent to request another agent to hold a specified amount
17/// of currency or asset from a party in escrow on behalf of another party.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "camelCase")]
20pub struct Escrow {
21    /// The specific cryptocurrency asset to be held in escrow (CAIP-19 identifier)
22    /// Either `asset` OR `currency` MUST be present
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub asset: Option<String>,
25
26    /// ISO 4217 currency code (e.g. "USD", "EUR") for fiat-denominated escrows
27    /// Either `asset` OR `currency` MUST be present
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub currency: Option<String>,
30
31    /// The amount to be held in escrow (string decimal)
32    pub amount: String,
33
34    /// The party whose assets will be placed in escrow
35    pub originator: Party,
36
37    /// The party who will receive the assets when released
38    pub beneficiary: Party,
39
40    /// Timestamp after which the escrow automatically expires and funds are released back to the originator
41    pub expiry: String,
42
43    /// URL or URI referencing the terms and conditions of the escrow
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub agreement: Option<String>,
46
47    /// Array of agents involved in the escrow. Exactly one agent MUST have role "EscrowAgent"
48    pub agents: Vec<Agent>,
49
50    /// Additional metadata
51    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
52    pub metadata: HashMap<String, Value>,
53}
54
55impl Escrow {
56    /// Create a new Escrow message for cryptocurrency assets
57    pub fn new_with_asset(
58        asset: String,
59        amount: String,
60        originator: Party,
61        beneficiary: Party,
62        expiry: String,
63        agents: Vec<Agent>,
64    ) -> Self {
65        Self {
66            asset: Some(asset),
67            currency: None,
68            amount,
69            originator,
70            beneficiary,
71            expiry,
72            agreement: None,
73            agents,
74            metadata: HashMap::new(),
75        }
76    }
77
78    /// Create a new Escrow message for fiat currency
79    pub fn new_with_currency(
80        currency: String,
81        amount: String,
82        originator: Party,
83        beneficiary: Party,
84        expiry: String,
85        agents: Vec<Agent>,
86    ) -> Self {
87        Self {
88            asset: None,
89            currency: Some(currency),
90            amount,
91            originator,
92            beneficiary,
93            expiry,
94            agreement: None,
95            agents,
96            metadata: HashMap::new(),
97        }
98    }
99
100    /// Set the agreement URL
101    pub fn with_agreement(mut self, agreement: String) -> Self {
102        self.agreement = Some(agreement);
103        self
104    }
105
106    /// Add metadata
107    pub fn with_metadata(mut self, key: String, value: Value) -> Self {
108        self.metadata.insert(key, value);
109        self
110    }
111
112    /// Find the escrow agent in the agents list
113    pub fn escrow_agent(&self) -> Option<&Agent> {
114        self.agents
115            .iter()
116            .find(|a| a.role == Some("EscrowAgent".to_string()))
117    }
118
119    /// Find agents that can authorize release (agents acting for the beneficiary)
120    pub fn authorizing_agents(&self) -> Vec<&Agent> {
121        self.agents
122            .iter()
123            .filter(|a| a.for_parties.0.contains(&self.beneficiary.id))
124            .collect()
125    }
126}
127
128impl TapMessageBody for Escrow {
129    fn message_type() -> &'static str {
130        "https://tap.rsvp/schema/1.0#Escrow"
131    }
132
133    fn validate(&self) -> Result<()> {
134        // Validate that either asset or currency is present, but not both
135        match (&self.asset, &self.currency) {
136            (Some(_), Some(_)) => {
137                return Err(Error::Validation(
138                    "Escrow cannot have both asset and currency specified".to_string(),
139                ));
140            }
141            (None, None) => {
142                return Err(Error::Validation(
143                    "Escrow must have either asset or currency specified".to_string(),
144                ));
145            }
146            _ => {}
147        }
148
149        // Validate amount is not empty
150        if self.amount.is_empty() {
151            return Err(Error::Validation(
152                "Escrow amount cannot be empty".to_string(),
153            ));
154        }
155
156        // Validate expiry is not empty
157        if self.expiry.is_empty() {
158            return Err(Error::Validation(
159                "Escrow expiry cannot be empty".to_string(),
160            ));
161        }
162
163        // Validate exactly one EscrowAgent exists
164        let escrow_agent_count = self
165            .agents
166            .iter()
167            .filter(|a| a.role == Some("EscrowAgent".to_string()))
168            .count();
169
170        if escrow_agent_count == 0 {
171            return Err(Error::Validation(
172                "Escrow must have exactly one agent with role 'EscrowAgent'".to_string(),
173            ));
174        }
175
176        if escrow_agent_count > 1 {
177            return Err(Error::Validation(
178                "Escrow cannot have more than one agent with role 'EscrowAgent'".to_string(),
179            ));
180        }
181
182        // Validate originator and beneficiary are different
183        if self.originator.id == self.beneficiary.id {
184            return Err(Error::Validation(
185                "Escrow originator and beneficiary must be different parties".to_string(),
186            ));
187        }
188
189        Ok(())
190    }
191}
192
193/// Capture message for releasing escrowed funds
194///
195/// The Capture message authorizes the release of escrowed funds to the beneficiary.
196/// It can only be sent by agents acting for the beneficiary.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198#[serde(rename_all = "camelCase")]
199pub struct Capture {
200    /// Amount to capture (string decimal). If omitted, captures full escrow amount.
201    /// MUST be less than or equal to original amount
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub amount: Option<String>,
204
205    /// Blockchain address for settlement. If omitted, uses address from earlier Authorize
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub settlement_address: Option<String>,
208
209    /// Additional metadata
210    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
211    pub metadata: HashMap<String, Value>,
212}
213
214impl Capture {
215    /// Create a new Capture message for the full amount
216    pub fn new() -> Self {
217        Self {
218            amount: None,
219            settlement_address: None,
220            metadata: HashMap::new(),
221        }
222    }
223
224    /// Create a new Capture message for a partial amount
225    pub fn with_amount(amount: String) -> Self {
226        Self {
227            amount: Some(amount),
228            settlement_address: None,
229            metadata: HashMap::new(),
230        }
231    }
232
233    /// Set the settlement address
234    pub fn with_settlement_address(mut self, address: String) -> Self {
235        self.settlement_address = Some(address);
236        self
237    }
238
239    /// Add metadata
240    pub fn with_metadata(mut self, key: String, value: Value) -> Self {
241        self.metadata.insert(key, value);
242        self
243    }
244}
245
246impl Default for Capture {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl TapMessageBody for Capture {
253    fn message_type() -> &'static str {
254        "https://tap.rsvp/schema/1.0#Capture"
255    }
256
257    fn validate(&self) -> Result<()> {
258        // Validate amount if present
259        if let Some(ref amount) = self.amount {
260            if amount.is_empty() {
261                return Err(Error::Validation(
262                    "Capture amount cannot be empty".to_string(),
263                ));
264            }
265        }
266
267        // Validate settlement_address if present
268        if let Some(ref address) = self.settlement_address {
269            if address.is_empty() {
270                return Err(Error::Validation(
271                    "Capture settlement_address cannot be empty".to_string(),
272                ));
273            }
274        }
275
276        Ok(())
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_escrow_with_asset() {
286        let originator = Party::new("did:example:alice");
287        let beneficiary = Party::new("did:example:bob");
288        let agent1 = Agent::new(
289            "did:example:alice-wallet",
290            "OriginatorAgent",
291            "did:example:alice",
292        );
293        let agent2 = Agent::new(
294            "did:example:bob-wallet",
295            "BeneficiaryAgent",
296            "did:example:bob",
297        );
298        let escrow_agent = Agent::new(
299            "did:example:escrow-service",
300            "EscrowAgent",
301            "did:example:escrow-service",
302        );
303
304        let escrow = Escrow::new_with_asset(
305            "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(),
306            "100.00".to_string(),
307            originator,
308            beneficiary,
309            "2025-06-25T00:00:00Z".to_string(),
310            vec![agent1, agent2, escrow_agent],
311        );
312
313        assert!(escrow.validate().is_ok());
314        assert!(escrow.escrow_agent().is_some());
315        assert_eq!(
316            escrow.escrow_agent().unwrap().role,
317            Some("EscrowAgent".to_string())
318        );
319    }
320
321    #[test]
322    fn test_escrow_with_currency() {
323        let originator = Party::new("did:example:buyer");
324        let beneficiary = Party::new("did:example:seller");
325        let escrow_agent = Agent::new(
326            "did:example:escrow-bank",
327            "EscrowAgent",
328            "did:example:escrow-bank",
329        );
330
331        let escrow = Escrow::new_with_currency(
332            "USD".to_string(),
333            "500.00".to_string(),
334            originator,
335            beneficiary,
336            "2025-07-01T00:00:00Z".to_string(),
337            vec![escrow_agent],
338        )
339        .with_agreement("https://marketplace.example/purchase/98765".to_string());
340
341        assert!(escrow.validate().is_ok());
342        assert_eq!(escrow.currency, Some("USD".to_string()));
343        assert_eq!(
344            escrow.agreement,
345            Some("https://marketplace.example/purchase/98765".to_string())
346        );
347    }
348
349    #[test]
350    fn test_escrow_validation_errors() {
351        let originator = Party::new("did:example:alice");
352        let beneficiary = Party::new("did:example:bob");
353
354        // Test missing escrow agent
355        let escrow_no_agent = Escrow::new_with_asset(
356            "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(),
357            "100.00".to_string(),
358            originator.clone(),
359            beneficiary.clone(),
360            "2025-06-25T00:00:00Z".to_string(),
361            vec![],
362        );
363        assert!(escrow_no_agent.validate().is_err());
364
365        // Test both asset and currency specified
366        let mut escrow_both = Escrow::new_with_asset(
367            "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(),
368            "100.00".to_string(),
369            originator.clone(),
370            beneficiary.clone(),
371            "2025-06-25T00:00:00Z".to_string(),
372            vec![Agent::new(
373                "did:example:escrow",
374                "EscrowAgent",
375                "did:example:escrow",
376            )],
377        );
378        escrow_both.currency = Some("USD".to_string());
379        assert!(escrow_both.validate().is_err());
380
381        // Test same originator and beneficiary
382        let escrow_same_party = Escrow::new_with_currency(
383            "USD".to_string(),
384            "100.00".to_string(),
385            originator.clone(),
386            originator.clone(),
387            "2025-06-25T00:00:00Z".to_string(),
388            vec![Agent::new(
389                "did:example:escrow",
390                "EscrowAgent",
391                "did:example:escrow",
392            )],
393        );
394        assert!(escrow_same_party.validate().is_err());
395    }
396
397    #[test]
398    fn test_capture() {
399        let capture = Capture::new();
400        assert!(capture.validate().is_ok());
401        assert!(capture.amount.is_none());
402        assert!(capture.settlement_address.is_none());
403
404        let capture_with_amount = Capture::with_amount("95.00".to_string())
405            .with_settlement_address(
406                "eip155:1:0x742d35Cc6634C0532925a3b844Bc9e7595f1234".to_string(),
407            );
408        assert!(capture_with_amount.validate().is_ok());
409        assert_eq!(capture_with_amount.amount, Some("95.00".to_string()));
410        assert_eq!(
411            capture_with_amount.settlement_address,
412            Some("eip155:1:0x742d35Cc6634C0532925a3b844Bc9e7595f1234".to_string())
413        );
414    }
415
416    #[test]
417    fn test_capture_validation_errors() {
418        let mut capture = Capture::new();
419        capture.amount = Some("".to_string());
420        assert!(capture.validate().is_err());
421
422        let mut capture2 = Capture::new();
423        capture2.settlement_address = Some("".to_string());
424        assert!(capture2.validate().is_err());
425    }
426}