Skip to main content

xrpl/models/transactions/
vault_clawback.rs

1use alloc::borrow::Cow;
2use alloc::string::ToString;
3use alloc::vec::Vec;
4use serde::{Deserialize, Serialize};
5use serde_with::skip_serializing_none;
6
7use bigdecimal::BigDecimal;
8use core::str::FromStr;
9
10use crate::core::addresscodec::is_valid_classic_address;
11use crate::models::amount::XRPAmount;
12use crate::models::{
13    Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelException, XRPLModelResult,
14};
15
16use super::vault_common::validate_vault_id;
17use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType};
18
19/// Claw back assets from a vault holder on the XRP Ledger (XLS-65).
20///
21/// The issuer of the vault's asset can claw back deposited assets from a
22/// specific holder, burning the holder's share tokens in the process.
23///
24/// See VaultClawback transaction:
25/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0065d-single-asset-vault>`
26#[skip_serializing_none]
27#[derive(
28    Debug,
29    Default,
30    Serialize,
31    Deserialize,
32    PartialEq,
33    Eq,
34    Clone,
35    xrpl_rust_macros::ValidateCurrencies,
36)]
37#[serde(rename_all = "PascalCase")]
38pub struct VaultClawback<'a> {
39    /// The base fields for all transaction models.
40    ///
41    /// See Transaction Common Fields:
42    /// `<https://xrpl.org/transaction-common-fields.html>`
43    #[serde(flatten)]
44    pub common_fields: CommonFields<'a, NoFlags>,
45    /// The ID of the vault to claw back from (256-bit hex string).
46    #[serde(rename = "VaultID")]
47    pub vault_id: Cow<'a, str>,
48    /// The account address of the holder whose assets are being clawed back.
49    pub holder: Cow<'a, str>,
50    /// The asset amount to claw back. Omit to claw back all funds up to the
51    /// total shares the Holder owns.
52    pub amount: Option<Amount<'a>>,
53}
54
55impl Model for VaultClawback<'_> {
56    fn get_errors(&self) -> XRPLModelResult<()> {
57        self.validate_currencies()?;
58        validate_vault_id(&self.vault_id)?;
59        if !is_valid_classic_address(self.holder.as_ref()) {
60            return Err(XRPLModelException::InvalidValue {
61                field: "holder".into(),
62                expected: "a valid classic account address".into(),
63                found: self.holder.as_ref().into(),
64            });
65        }
66        if let Some(amount) = &self.amount {
67            let value = match amount {
68                Amount::MPTAmount(amount) => amount.value.as_ref(),
69                Amount::IssuedCurrencyAmount(amount) => amount.value.as_ref(),
70                Amount::XRPAmount(amount) => {
71                    return Err(XRPLModelException::InvalidValue {
72                        field: "amount".into(),
73                        expected: "an IOU or MPT amount, or omitted".into(),
74                        found: amount.0.to_string(),
75                    });
76                }
77            };
78            let parsed = BigDecimal::from_str(value).map_err(|_| {
79                XRPLModelException::InvalidValueFormat {
80                    field: "amount".into(),
81                    format: "a valid decimal number".into(),
82                    found: value.into(),
83                }
84            })?;
85            // xrpld VaultClawback preflight: zero amount is valid (means "all").
86            // Only negative values are temBAD_AMOUNT.
87            if parsed < 0 {
88                return Err(XRPLModelException::InvalidValue {
89                    field: "amount".into(),
90                    expected: "a nonnegative amount".into(),
91                    found: value.into(),
92                });
93            }
94        }
95        Ok(())
96    }
97}
98
99impl<'a> Transaction<'a, NoFlags> for VaultClawback<'a> {
100    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
101        &self.common_fields
102    }
103
104    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
105        &mut self.common_fields
106    }
107
108    fn get_transaction_type(&self) -> &TransactionType {
109        self.common_fields.get_transaction_type()
110    }
111}
112
113impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultClawback<'a> {
114    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
115        &mut self.common_fields
116    }
117
118    fn into_self(self) -> Self {
119        self
120    }
121}
122
123impl<'a> VaultClawback<'a> {
124    pub fn new(
125        account: Cow<'a, str>,
126        account_txn_id: Option<Cow<'a, str>>,
127        fee: Option<XRPAmount<'a>>,
128        last_ledger_sequence: Option<u32>,
129        memos: Option<Vec<Memo>>,
130        sequence: Option<u32>,
131        signers: Option<Vec<Signer>>,
132        source_tag: Option<u32>,
133        ticket_sequence: Option<u32>,
134        vault_id: Cow<'a, str>,
135        holder: Cow<'a, str>,
136        amount: Option<Amount<'a>>,
137    ) -> VaultClawback<'a> {
138        VaultClawback {
139            common_fields: CommonFields::new(
140                account,
141                TransactionType::VaultClawback,
142                account_txn_id,
143                fee,
144                Some(FlagCollection::default()),
145                last_ledger_sequence,
146                memos,
147                None, // network_id
148                sequence,
149                signers,
150                None, // signing_pub_key
151                source_tag,
152                ticket_sequence,
153                None, // txn_signature
154            ),
155            vault_id,
156            holder,
157            amount,
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::models::amount::Amount;
166    use crate::utils::testing::test_constants::*;
167
168    const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF";
169
170    #[test]
171    fn test_serde() {
172        let vault_clawback = VaultClawback {
173            common_fields: CommonFields {
174                account: "rIssuer123".into(),
175                transaction_type: TransactionType::VaultClawback,
176                signing_pub_key: Some("".into()),
177                ..Default::default()
178            },
179            vault_id: VAULT_ID.into(),
180            holder: "rHolder456".into(),
181            amount: Some("500".into()),
182        };
183
184        let json_str = r#"{"Account":"rIssuer123","TransactionType":"VaultClawback","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Holder":"rHolder456","Amount":"500"}"#;
185
186        // Serialize
187        let serialized = serde_json::to_string(&vault_clawback).unwrap();
188        assert_eq!(
189            serde_json::to_value(&serialized).unwrap(),
190            serde_json::to_value(json_str).unwrap()
191        );
192
193        // Deserialize
194        let deserialized: VaultClawback = serde_json::from_str(json_str).unwrap();
195        assert_eq!(vault_clawback, deserialized);
196    }
197
198    #[test]
199    fn test_serde_no_amount() {
200        let vault_clawback = VaultClawback {
201            common_fields: CommonFields {
202                account: "rIssuerNoAmt789".into(),
203                transaction_type: TransactionType::VaultClawback,
204                signing_pub_key: Some("".into()),
205                ..Default::default()
206            },
207            vault_id: VAULT_ID.into(),
208            holder: "rHolderNoAmt012".into(),
209            amount: None,
210        };
211
212        let serialized = serde_json::to_string(&vault_clawback).unwrap();
213        let deserialized: VaultClawback = serde_json::from_str(&serialized).unwrap();
214        assert_eq!(vault_clawback, deserialized);
215    }
216
217    #[test]
218    fn test_builder_pattern() {
219        let vault_clawback = VaultClawback {
220            common_fields: CommonFields {
221                account: "rIssuer123".into(),
222                transaction_type: TransactionType::VaultClawback,
223                ..Default::default()
224            },
225            vault_id: VAULT_ID.into(),
226            holder: "rHolder456".into(),
227            amount: Some("500".into()),
228        }
229        .with_fee("12".into())
230        .with_sequence(100)
231        .with_last_ledger_sequence(7108682)
232        .with_source_tag(12345)
233        .with_memo(Memo {
234            memo_data: Some("clawback from holder".into()),
235            memo_format: None,
236            memo_type: Some("text".into()),
237        });
238
239        assert_eq!(vault_clawback.vault_id, VAULT_ID);
240        assert_eq!(vault_clawback.holder, "rHolder456");
241        assert_eq!(vault_clawback.common_fields.fee.as_ref().unwrap().0, "12");
242        assert_eq!(vault_clawback.common_fields.sequence, Some(100));
243        assert_eq!(
244            vault_clawback.common_fields.last_ledger_sequence,
245            Some(7108682)
246        );
247        assert_eq!(vault_clawback.common_fields.source_tag, Some(12345));
248        assert_eq!(
249            vault_clawback.common_fields.memos.as_ref().unwrap().len(),
250            1
251        );
252    }
253
254    #[test]
255    fn test_default() {
256        let vault_clawback = VaultClawback {
257            common_fields: CommonFields {
258                account: "rIssuer789".into(),
259                transaction_type: TransactionType::VaultClawback,
260                ..Default::default()
261            },
262            vault_id: VAULT_ID.into(),
263            holder: "rHolder012".into(),
264            amount: Some("100000".into()),
265        };
266
267        assert_eq!(vault_clawback.common_fields.account, "rIssuer789");
268        assert_eq!(
269            vault_clawback.common_fields.transaction_type,
270            TransactionType::VaultClawback
271        );
272        assert_eq!(vault_clawback.vault_id, VAULT_ID);
273        assert_eq!(vault_clawback.holder, "rHolder012");
274        assert!(vault_clawback.common_fields.fee.is_none());
275        assert!(vault_clawback.common_fields.sequence.is_none());
276    }
277
278    #[test]
279    fn test_ticket_sequence() {
280        let ticket_clawback = VaultClawback {
281            common_fields: CommonFields {
282                account: "rTicketIssuer111".into(),
283                transaction_type: TransactionType::VaultClawback,
284                ..Default::default()
285            },
286            vault_id: VAULT_ID.into(),
287            holder: "rTicketHolder222".into(),
288            amount: Some("2000000".into()),
289        }
290        .with_ticket_sequence(54321)
291        .with_fee("12".into());
292
293        assert_eq!(ticket_clawback.common_fields.ticket_sequence, Some(54321));
294        assert!(ticket_clawback.common_fields.sequence.is_none());
295    }
296
297    #[test]
298    fn test_multiple_memos() {
299        let multi_memo_clawback = VaultClawback {
300            common_fields: CommonFields {
301                account: "rMultiMemoIssuer333".into(),
302                transaction_type: TransactionType::VaultClawback,
303                ..Default::default()
304            },
305            vault_id: VAULT_ID.into(),
306            holder: "rMultiMemoHolder444".into(),
307            amount: Some("1000".into()),
308        }
309        .with_memo(Memo {
310            memo_data: Some("compliance action".into()),
311            memo_format: None,
312            memo_type: Some("text".into()),
313        })
314        .with_memo(Memo {
315            memo_data: Some("regulatory requirement".into()),
316            memo_format: None,
317            memo_type: Some("text".into()),
318        })
319        .with_fee("18".into())
320        .with_sequence(400);
321
322        assert_eq!(
323            multi_memo_clawback
324                .common_fields
325                .memos
326                .as_ref()
327                .unwrap()
328                .len(),
329            2
330        );
331        assert_eq!(multi_memo_clawback.common_fields.sequence, Some(400));
332    }
333
334    #[test]
335    fn test_new_constructor() {
336        let vault_clawback = VaultClawback {
337            common_fields: CommonFields {
338                account: "rNewIssuer555".into(),
339                transaction_type: TransactionType::VaultClawback,
340                fee: Some("12".into()),
341                last_ledger_sequence: Some(7108682),
342                sequence: Some(100),
343                ..Default::default()
344            },
345            vault_id: VAULT_ID.into(),
346            holder: "rNewHolder666".into(),
347            amount: Some(Amount::IssuedCurrencyAmount(
348                crate::models::amount::IssuedCurrencyAmount::new(
349                    "XRP".into(),
350                    "rNewIssuer555".into(),
351                    "750".into(),
352                ),
353            )),
354        };
355
356        assert_eq!(vault_clawback.common_fields.account, "rNewIssuer555");
357        assert_eq!(
358            vault_clawback.common_fields.transaction_type,
359            TransactionType::VaultClawback
360        );
361        assert_eq!(vault_clawback.common_fields.fee.as_ref().unwrap().0, "12");
362        assert_eq!(vault_clawback.vault_id, VAULT_ID);
363        assert_eq!(vault_clawback.holder, "rNewHolder666");
364    }
365
366    #[test]
367    fn test_validate() {
368        let vault_clawback = VaultClawback {
369            common_fields: CommonFields {
370                account: ACCOUNT_ISSUER.into(),
371                transaction_type: TransactionType::VaultClawback,
372                ..Default::default()
373            },
374            vault_id: VAULT_ID.into(),
375            holder: ACCOUNT_HOLDER.into(),
376            amount: Some(
377                crate::models::IssuedCurrencyAmount::new(
378                    "USD".into(),
379                    ACCOUNT_ISSUER.into(),
380                    "100".into(),
381                )
382                .into(),
383            ),
384        }
385        .with_fee("12".into())
386        .with_sequence(300);
387
388        assert!(vault_clawback.validate().is_ok());
389    }
390
391    #[test]
392    fn test_clawback_all_no_amount() {
393        let vault_clawback = VaultClawback {
394            common_fields: CommonFields {
395                account: ACCOUNT_ISSUER.into(),
396                transaction_type: TransactionType::VaultClawback,
397                fee: Some("12".into()),
398                sequence: Some(200),
399                ..Default::default()
400            },
401            vault_id: VAULT_ID.into(),
402            holder: ACCOUNT_HOLDER.into(),
403            amount: None,
404        };
405
406        assert!(vault_clawback.amount.is_none());
407        assert!(vault_clawback.validate().is_ok());
408    }
409
410    #[test]
411    fn test_holder_invalid_rejected() {
412        let clawback = VaultClawback {
413            common_fields: CommonFields {
414                account: ACCOUNT_ISSUER.into(),
415                transaction_type: TransactionType::VaultClawback,
416                ..Default::default()
417            },
418            vault_id: VAULT_ID.into(),
419            holder: "notanaddress".into(),
420            amount: None,
421        };
422        assert!(clawback.validate().is_err());
423    }
424
425    #[test]
426    fn test_amount_xrp_rejected() {
427        let clawback = VaultClawback {
428            common_fields: CommonFields {
429                account: ACCOUNT_ISSUER.into(),
430                transaction_type: TransactionType::VaultClawback,
431                fee: Some("12".into()),
432                sequence: Some(1),
433                ..Default::default()
434            },
435            vault_id: VAULT_ID.into(),
436            holder: ACCOUNT_HOLDER.into(),
437            amount: Some(Amount::XRPAmount("500".into())),
438        };
439        assert!(clawback.validate().is_err());
440    }
441
442    #[test]
443    fn test_amount_zero_accepted() {
444        // xrpld VaultClawback: zero amount is valid ("claw back all"). Only negative
445        // is temBAD_AMOUNT. Explicit zero is equivalent to omitting the field.
446        let clawback = VaultClawback {
447            common_fields: CommonFields {
448                account: ACCOUNT_ISSUER.into(),
449                transaction_type: TransactionType::VaultClawback,
450                fee: Some("12".into()),
451                sequence: Some(1),
452                ..Default::default()
453            },
454            vault_id: VAULT_ID.into(),
455            holder: ACCOUNT_HOLDER.into(),
456            amount: Some(Amount::IssuedCurrencyAmount(
457                crate::models::amount::IssuedCurrencyAmount::new(
458                    "USD".into(),
459                    ACCOUNT_HOLDER_2.into(),
460                    "0".into(),
461                ),
462            )),
463        };
464        assert!(clawback.validate().is_ok());
465    }
466
467    #[test]
468    fn test_amount_negative_rejected() {
469        let clawback = VaultClawback {
470            common_fields: CommonFields {
471                account: ACCOUNT_ISSUER.into(),
472                transaction_type: TransactionType::VaultClawback,
473                fee: Some("12".into()),
474                sequence: Some(1),
475                ..Default::default()
476            },
477            vault_id: VAULT_ID.into(),
478            holder: ACCOUNT_HOLDER.into(),
479            amount: Some(Amount::IssuedCurrencyAmount(
480                crate::models::amount::IssuedCurrencyAmount::new(
481                    "USD".into(),
482                    ACCOUNT_HOLDER_2.into(),
483                    "-100".into(),
484                ),
485            )),
486        };
487        assert!(clawback.validate().is_err());
488    }
489
490    #[test]
491    fn test_amount_not_a_number_rejected() {
492        let clawback = VaultClawback {
493            common_fields: CommonFields {
494                account: ACCOUNT_ISSUER.into(),
495                transaction_type: TransactionType::VaultClawback,
496                fee: Some("12".into()),
497                sequence: Some(1),
498                ..Default::default()
499            },
500            vault_id: VAULT_ID.into(),
501            holder: ACCOUNT_HOLDER.into(),
502            amount: Some(Amount::IssuedCurrencyAmount(
503                crate::models::amount::IssuedCurrencyAmount::new(
504                    "USD".into(),
505                    ACCOUNT_HOLDER_2.into(),
506                    "not-a-number".into(),
507                ),
508            )),
509        };
510        assert!(clawback.validate().is_err());
511    }
512
513    #[test]
514    fn test_amount_valid_ica_accepted() {
515        let clawback = VaultClawback {
516            common_fields: CommonFields {
517                account: ACCOUNT_ISSUER.into(),
518                transaction_type: TransactionType::VaultClawback,
519                fee: Some("12".into()),
520                sequence: Some(1),
521                ..Default::default()
522            },
523            vault_id: VAULT_ID.into(),
524            holder: ACCOUNT_HOLDER.into(),
525            amount: Some(Amount::IssuedCurrencyAmount(
526                crate::models::amount::IssuedCurrencyAmount::new(
527                    "USD".into(),
528                    ACCOUNT_HOLDER_2.into(),
529                    "100".into(),
530                ),
531            )),
532        };
533        assert!(clawback.validate().is_ok());
534    }
535
536    #[test]
537    fn test_amount_mpt_positive_accepted() {
538        use crate::models::amount::MPTAmount;
539        let clawback = VaultClawback {
540            common_fields: CommonFields {
541                account: ACCOUNT_ISSUER.into(),
542                transaction_type: TransactionType::VaultClawback,
543                fee: Some("12".into()),
544                sequence: Some(1),
545                ..Default::default()
546            },
547            vault_id: VAULT_ID.into(),
548            holder: ACCOUNT_HOLDER.into(),
549            amount: Some(Amount::MPTAmount(MPTAmount {
550                mpt_issuance_id: "000000016B4E90A4B36D74F6E16A5BED41EBD7AA37B19B89".into(),
551                value: "500".into(),
552            })),
553        };
554        assert!(clawback.validate().is_ok());
555    }
556
557    #[test]
558    fn test_amount_mpt_negative_rejected() {
559        use crate::models::amount::MPTAmount;
560        let clawback = VaultClawback {
561            common_fields: CommonFields {
562                account: ACCOUNT_ISSUER.into(),
563                transaction_type: TransactionType::VaultClawback,
564                fee: Some("12".into()),
565                sequence: Some(1),
566                ..Default::default()
567            },
568            vault_id: VAULT_ID.into(),
569            holder: ACCOUNT_HOLDER.into(),
570            amount: Some(Amount::MPTAmount(MPTAmount {
571                mpt_issuance_id: "000000016B4E90A4B36D74F6E16A5BED41EBD7AA37B19B89".into(),
572                value: "-1".into(),
573            })),
574        };
575        assert!(clawback.validate().is_err());
576    }
577
578    #[test]
579    fn test_vault_id_invalid_rejected() {
580        // vault_id too short
581        let clawback = VaultClawback {
582            common_fields: CommonFields {
583                account: ACCOUNT_ISSUER.into(),
584                transaction_type: TransactionType::VaultClawback,
585                ..Default::default()
586            },
587            vault_id: "TOOSHORT".into(),
588            holder: ACCOUNT_HOLDER.into(),
589            amount: None,
590        };
591        assert!(clawback.validate().is_err());
592    }
593}