Skip to main content

xrpl/models/transactions/
clawback.rs

1use alloc::borrow::Cow;
2use alloc::vec::Vec;
3
4use serde::{Deserialize, Serialize};
5use serde_with::skip_serializing_none;
6
7use crate::models::amount::XRPAmount;
8use crate::models::transactions::CommonFields;
9use crate::models::{
10    amount::Amount,
11    transactions::{Transaction, TransactionType},
12    Model, ValidateCurrencies,
13};
14use crate::models::{FlagCollection, NoFlags};
15
16use super::exceptions::XRPLClawbackException;
17use super::mptoken_issuance_set::validate_holder_address;
18use super::{CommonTransactionBuilder, Memo, Signer};
19
20/// Claws back issued currency amount or MPT issued by the sender.
21///
22/// For IssuedCurrencyAmount: `amount.issuer` must be the token holder's address
23/// and `Holder` must be absent.
24/// For MPTAmount: `Holder` must be present and must not equal `Account`.
25///
26/// See Clawback:
27/// `<https://xrpl.org/docs/references/protocol/transactions/types/clawback>`
28#[skip_serializing_none]
29#[derive(
30    Debug,
31    Default,
32    Serialize,
33    Deserialize,
34    PartialEq,
35    Eq,
36    Clone,
37    xrpl_rust_macros::ValidateCurrencies,
38)]
39#[serde(rename_all = "PascalCase")]
40pub struct Clawback<'a> {
41    /// The base fields for all transaction models.
42    ///
43    /// See Transaction Common Fields:
44    /// `<https://xrpl.org/transaction-common-fields.html>`
45    #[serde(flatten)]
46    pub common_fields: CommonFields<'a, NoFlags>,
47    /// The amount to claw back. Must be IssuedCurrencyAmount or MPTAmount (not XRP).
48    /// For ICA: `amount.issuer` must be the holder's address.
49    /// For MPT: supply the `holder` field instead.
50    pub amount: Amount<'a>,
51    /// (MPT only) The account to claw back from. Required when `amount` is
52    /// MPTAmount; must not equal the transaction `account`.
53    pub holder: Option<Cow<'a, str>>,
54}
55
56pub trait ClawbackError {
57    fn _get_amount_error(&self) -> crate::models::XRPLModelResult<()>;
58    fn _get_holder_error(&self) -> crate::models::XRPLModelResult<()>;
59}
60
61impl<'a> ClawbackError for Clawback<'a> {
62    fn _get_amount_error(&self) -> crate::models::XRPLModelResult<()> {
63        if self.amount.is_xrp() {
64            return Err(XRPLClawbackException::AmountMustNotBeXRP.into());
65        }
66        self.amount.get_errors()
67    }
68
69    fn _get_holder_error(&self) -> crate::models::XRPLModelResult<()> {
70        match &self.amount {
71            Amount::IssuedCurrencyAmount(ica) => {
72                if self.common_fields.account == ica.issuer {
73                    return Err(XRPLClawbackException::IssuerMustNotEqualAccount.into());
74                }
75                if self.holder.is_some() {
76                    return Err(XRPLClawbackException::HolderMustNotBePresentForIOU.into());
77                }
78                Ok(())
79            }
80            Amount::MPTAmount(_) => match &self.holder {
81                None => Err(XRPLClawbackException::HolderRequiredForMPT.into()),
82                Some(holder) if holder.as_ref() == self.common_fields.account.as_ref() => {
83                    Err(XRPLClawbackException::HolderMustNotEqualAccount.into())
84                }
85                Some(holder) => validate_holder_address(holder.as_ref()),
86            },
87            Amount::XRPAmount(_) => Ok(()),
88        }
89    }
90}
91
92impl<'a> Model for Clawback<'a> {
93    fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
94        self._get_amount_error()?;
95        self._get_holder_error()?;
96        self.validate_currencies()
97    }
98}
99
100impl<'a> Transaction<'a, NoFlags> for Clawback<'a> {
101    fn get_transaction_type(&self) -> &TransactionType {
102        self.common_fields.get_transaction_type()
103    }
104
105    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
106        self.common_fields.get_common_fields()
107    }
108
109    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
110        self.common_fields.get_mut_common_fields()
111    }
112}
113
114impl<'a> CommonTransactionBuilder<'a, NoFlags> for Clawback<'a> {
115    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
116        &mut self.common_fields
117    }
118
119    fn into_self(self) -> Self {
120        self
121    }
122}
123
124impl<'a> Clawback<'a> {
125    pub fn new(
126        account: Cow<'a, str>,
127        account_txn_id: Option<Cow<'a, str>>,
128        fee: Option<XRPAmount<'a>>,
129        last_ledger_sequence: Option<u32>,
130        memos: Option<Vec<Memo>>,
131        sequence: Option<u32>,
132        signers: Option<Vec<Signer>>,
133        source_tag: Option<u32>,
134        ticket_sequence: Option<u32>,
135        amount: Amount<'a>,
136        holder: Option<Cow<'a, str>>,
137    ) -> Self {
138        Self {
139            common_fields: CommonFields::new(
140                account,
141                TransactionType::Clawback,
142                account_txn_id,
143                fee,
144                Some(FlagCollection::default()),
145                last_ledger_sequence,
146                memos,
147                None,
148                sequence,
149                signers,
150                None,
151                source_tag,
152                ticket_sequence,
153                None,
154            ),
155            amount,
156            holder,
157        }
158    }
159
160    pub fn with_holder(mut self, holder: Cow<'a, str>) -> Self {
161        self.holder = Some(holder);
162        self
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::models::amount::IssuedCurrencyAmount;
170    use crate::utils::testing::test_constants::*;
171
172    #[test]
173    fn test_serde() {
174        let default_txn = Clawback {
175            common_fields: CommonFields {
176                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
177                transaction_type: TransactionType::Clawback,
178                fee: Some("12".into()),
179                signing_pub_key: Some("".into()),
180                ..Default::default()
181            },
182            amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
183                "FOO".into(),
184                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
185                "314.159".into(),
186            )),
187            holder: None,
188        };
189
190        let default_json_str = r#"{"Account":"rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S","TransactionType":"Clawback","Fee":"12","Flags":0,"SigningPubKey":"","Amount":{"currency":"FOO","issuer":"rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW","value":"314.159"}}"#;
191
192        let serialized_string = serde_json::to_string(&default_txn).unwrap();
193        let actual: serde_json::Value = serde_json::from_str(&serialized_string).unwrap();
194        let expected: serde_json::Value = serde_json::from_str(default_json_str).unwrap();
195        assert_eq!(actual, expected);
196
197        let deserialized: Clawback = serde_json::from_str(default_json_str).unwrap();
198        assert_eq!(default_txn, deserialized);
199    }
200
201    #[test]
202    fn test_serde_with_holder() {
203        let txn = Clawback {
204            common_fields: CommonFields {
205                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
206                transaction_type: TransactionType::Clawback,
207                fee: Some("12".into()),
208                signing_pub_key: Some("".into()),
209                ..Default::default()
210            },
211            amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
212                "FOO".into(),
213                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
214                "314.159".into(),
215            )),
216            holder: Some("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into()),
217        };
218
219        let json_str = r#"{"Account":"rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S","TransactionType":"Clawback","Fee":"12","Flags":0,"SigningPubKey":"","Amount":{"currency":"FOO","issuer":"rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW","value":"314.159"},"Holder":"rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"}"#;
220
221        let serialized_string = serde_json::to_string(&txn).unwrap();
222        let actual: serde_json::Value = serde_json::from_str(&serialized_string).unwrap();
223        let expected: serde_json::Value = serde_json::from_str(json_str).unwrap();
224        assert_eq!(actual, expected);
225
226        let deserialized: Clawback = serde_json::from_str(json_str).unwrap();
227        assert_eq!(txn, deserialized);
228    }
229
230    #[test]
231    fn test_builder_pattern() {
232        let clawback = Clawback {
233            common_fields: CommonFields {
234                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
235                transaction_type: TransactionType::Clawback,
236                ..Default::default()
237            },
238            amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
239                "FOO".into(),
240                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
241                "314.159".into(),
242            )),
243            ..Default::default()
244        }
245        .with_fee("12".into())
246        .with_sequence(123)
247        .with_last_ledger_sequence(7108682)
248        .with_source_tag(12345);
249
250        assert_eq!(
251            clawback.common_fields.account,
252            "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S"
253        );
254        assert_eq!(clawback.common_fields.fee.as_ref().unwrap().0, "12");
255        assert_eq!(clawback.common_fields.sequence, Some(123));
256        assert_eq!(clawback.common_fields.last_ledger_sequence, Some(7108682));
257        assert_eq!(clawback.common_fields.source_tag, Some(12345));
258        assert_eq!(
259            clawback.common_fields.transaction_type,
260            TransactionType::Clawback
261        );
262        assert!(clawback.holder.is_none());
263    }
264
265    #[test]
266    fn test_validation_xrp_amount_rejected() {
267        use crate::models::amount::XRPAmount;
268        use crate::models::transactions::exceptions::{
269            XRPLClawbackException, XRPLTransactionException,
270        };
271        use crate::models::XRPLModelException;
272
273        let clawback = Clawback {
274            common_fields: CommonFields {
275                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
276                transaction_type: TransactionType::Clawback,
277                ..Default::default()
278            },
279            amount: Amount::XRPAmount(XRPAmount::from("1000000")),
280            ..Default::default()
281        };
282
283        let err = clawback.validate().unwrap_err();
284        assert!(
285            matches!(
286                err,
287                XRPLModelException::XRPLTransactionError(
288                    XRPLTransactionException::XRPLClawbackError(
289                        XRPLClawbackException::AmountMustNotBeXRP
290                    )
291                )
292            ),
293            "Expected AmountMustNotBeXRP, got: {:?}",
294            err
295        );
296    }
297
298    #[test]
299    fn test_validation_holder_present_for_iou_rejected() {
300        use crate::models::transactions::exceptions::{
301            XRPLClawbackException, XRPLTransactionException,
302        };
303        use crate::models::XRPLModelException;
304
305        let clawback = Clawback {
306            common_fields: CommonFields {
307                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
308                transaction_type: TransactionType::Clawback,
309                ..Default::default()
310            },
311            amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
312                "FOO".into(),
313                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
314                "314.159".into(),
315            )),
316            holder: Some("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into()),
317        };
318
319        let err = clawback.validate().unwrap_err();
320        assert!(
321            matches!(
322                err,
323                XRPLModelException::XRPLTransactionError(
324                    XRPLTransactionException::XRPLClawbackError(
325                        XRPLClawbackException::HolderMustNotBePresentForIOU
326                    )
327                )
328            ),
329            "Expected HolderMustNotBePresentForIOU, got: {:?}",
330            err
331        );
332    }
333
334    #[test]
335    fn test_validation_valid_iou_clawback() {
336        let clawback = Clawback {
337            common_fields: CommonFields {
338                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
339                transaction_type: TransactionType::Clawback,
340                ..Default::default()
341            },
342            amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
343                "FOO".into(),
344                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
345                "314.159".into(),
346            )),
347            holder: None,
348        };
349
350        assert!(
351            clawback.validate().is_ok(),
352            "Valid IOU clawback should pass validation"
353        );
354    }
355
356    #[test]
357    fn test_default() {
358        let clawback = Clawback {
359            common_fields: CommonFields {
360                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
361                transaction_type: TransactionType::Clawback,
362                ..Default::default()
363            },
364            amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
365                "USD".into(),
366                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
367                "100".into(),
368            )),
369            ..Default::default()
370        };
371
372        assert_eq!(
373            clawback.common_fields.account,
374            "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S"
375        );
376        assert_eq!(
377            clawback.common_fields.transaction_type,
378            TransactionType::Clawback
379        );
380        assert!(clawback.holder.is_none());
381        assert!(clawback.common_fields.fee.is_none());
382        assert!(clawback.common_fields.sequence.is_none());
383    }
384
385    #[test]
386    fn test_new_constructor() {
387        let clawback = Clawback::new(
388            "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
389            None,
390            Some("12".into()),
391            Some(7108682),
392            None,
393            Some(123),
394            None,
395            Some(12345),
396            None,
397            Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
398                "FOO".into(),
399                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
400                "314.159".into(),
401            )),
402            None,
403        );
404
405        assert_eq!(
406            clawback.common_fields.account,
407            "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S"
408        );
409        assert_eq!(
410            clawback.common_fields.transaction_type,
411            TransactionType::Clawback
412        );
413        assert_eq!(clawback.common_fields.fee.as_ref().unwrap().0, "12");
414        assert_eq!(clawback.common_fields.sequence, Some(123));
415        assert_eq!(clawback.common_fields.last_ledger_sequence, Some(7108682));
416        assert_eq!(clawback.common_fields.source_tag, Some(12345));
417        assert!(clawback.holder.is_none());
418        assert!(clawback.validate().is_ok());
419    }
420
421    #[test]
422    fn test_with_holder_builder() {
423        let clawback = Clawback {
424            common_fields: CommonFields {
425                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
426                transaction_type: TransactionType::Clawback,
427                ..Default::default()
428            },
429            amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
430                "FOO".into(),
431                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
432                "314.159".into(),
433            )),
434            ..Default::default()
435        }
436        .with_holder("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into());
437
438        assert_eq!(
439            clawback.holder.as_deref(),
440            Some("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW")
441        );
442    }
443
444    #[test]
445    fn test_transaction_trait_getters() {
446        let mut clawback = Clawback {
447            common_fields: CommonFields {
448                account: "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S".into(),
449                transaction_type: TransactionType::Clawback,
450                ..Default::default()
451            },
452            amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
453                "FOO".into(),
454                "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
455                "314.159".into(),
456            )),
457            holder: None,
458        };
459
460        assert_eq!(
461            Transaction::get_transaction_type(&clawback),
462            &TransactionType::Clawback
463        );
464        assert_eq!(
465            Transaction::get_common_fields(&clawback).account,
466            "rp6abvbTbjoce8ZDJkT6snvxTZSYMBCC9S"
467        );
468
469        let common_mut = Transaction::get_mut_common_fields(&mut clawback);
470        common_mut.sequence = Some(42);
471        assert_eq!(clawback.common_fields.sequence, Some(42));
472    }
473
474    #[test]
475    fn test_clawback_ica_valid_holder_differs_from_account() {
476        let account = ACCOUNT_HOLDER;
477        let holder = ACCOUNT_HOLDER_2;
478        let amount =
479            Amount::IssuedCurrencyAmount(crate::models::amount::IssuedCurrencyAmount::new(
480                "USD".into(),
481                holder.into(),
482                "100".into(),
483            ));
484
485        let clawback = Clawback {
486            common_fields: CommonFields {
487                account: account.into(),
488                transaction_type: TransactionType::Clawback,
489                fee: Some("12".into()),
490                sequence: Some(1),
491                ..Default::default()
492            },
493            amount,
494            holder: None,
495        };
496
497        assert!(clawback.get_errors().is_ok());
498    }
499
500    #[test]
501    fn test_clawback_ica_rejects_self_clawback() {
502        let account = ACCOUNT_HOLDER;
503        let amount =
504            Amount::IssuedCurrencyAmount(crate::models::amount::IssuedCurrencyAmount::new(
505                "USD".into(),
506                account.into(),
507                "100".into(),
508            ));
509
510        let clawback = Clawback {
511            common_fields: CommonFields {
512                account: account.into(),
513                transaction_type: TransactionType::Clawback,
514                fee: Some("12".into()),
515                sequence: Some(1),
516                ..Default::default()
517            },
518            amount,
519            holder: None,
520        };
521
522        assert!(clawback.get_errors().is_err());
523    }
524
525    #[test]
526    fn test_clawback_mpt_valid() {
527        let account = ACCOUNT_HOLDER;
528        let holder = ACCOUNT_HOLDER_2;
529        let amount = Amount::MPTAmount(crate::models::amount::MPTAmount::new(
530            "100".into(),
531            "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
532        ));
533        let clawback = Clawback {
534            common_fields: CommonFields {
535                account: account.into(),
536                transaction_type: TransactionType::Clawback,
537                fee: Some("12".into()),
538                sequence: Some(1),
539                ..Default::default()
540            },
541            amount,
542            holder: Some(holder.into()),
543        };
544        assert!(clawback.get_errors().is_ok());
545    }
546
547    #[test]
548    fn test_clawback_mpt_missing_holder() {
549        let account = ACCOUNT_HOLDER;
550        let amount = Amount::MPTAmount(crate::models::amount::MPTAmount::new(
551            "100".into(),
552            "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
553        ));
554        let clawback = Clawback {
555            common_fields: CommonFields {
556                account: account.into(),
557                transaction_type: TransactionType::Clawback,
558                fee: Some("12".into()),
559                sequence: Some(1),
560                ..Default::default()
561            },
562            amount,
563            holder: None,
564        };
565        assert!(clawback.get_errors().is_err());
566    }
567
568    #[test]
569    fn test_clawback_mpt_holder_equals_account() {
570        let account = ACCOUNT_HOLDER;
571        let amount = Amount::MPTAmount(crate::models::amount::MPTAmount::new(
572            "100".into(),
573            "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
574        ));
575        let clawback = Clawback {
576            common_fields: CommonFields {
577                account: account.into(),
578                transaction_type: TransactionType::Clawback,
579                fee: Some("12".into()),
580                sequence: Some(1),
581                ..Default::default()
582            },
583            amount,
584            holder: Some(account.into()),
585        };
586        assert!(clawback.get_errors().is_err());
587    }
588
589    #[test]
590    fn test_clawback_mpt_invalid_holder_address() {
591        let account = ACCOUNT_HOLDER;
592        let amount = Amount::MPTAmount(crate::models::amount::MPTAmount::new(
593            "100".into(),
594            "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
595        ));
596        let clawback = Clawback {
597            common_fields: CommonFields {
598                account: account.into(),
599                transaction_type: TransactionType::Clawback,
600                fee: Some("12".into()),
601                sequence: Some(1),
602                ..Default::default()
603            },
604            amount,
605            holder: Some("not-a-valid-xrpl-address".into()),
606        };
607        assert!(clawback.get_errors().is_err());
608    }
609
610    #[test]
611    fn test_clawback_mpt_rejects_malformed_issuance_id() {
612        // mpt_issuance_id must be 48 hex chars (192-bit Hash192). This is only
613        // 8 chars — MPTAmount::get_errors() must reject it via _get_amount_error().
614        let account = ACCOUNT_HOLDER_2;
615        let holder = ACCOUNT_HOLDER;
616        let clawback = Clawback {
617            common_fields: CommonFields {
618                account: account.into(),
619                transaction_type: TransactionType::Clawback,
620                fee: Some("12".into()),
621                sequence: Some(1),
622                ..Default::default()
623            },
624            amount: Amount::MPTAmount(crate::models::amount::MPTAmount::new(
625                "100".into(),
626                "DEADBEEF".into(), // too short — not a valid Hash192
627            )),
628            holder: Some(holder.into()),
629        };
630        assert!(
631            clawback.get_errors().is_err(),
632            "expected get_errors() to fail for malformed mpt_issuance_id"
633        );
634    }
635}