Skip to main content

tap_msg/message/
lock.rs

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