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#[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 #[serde(flatten)]
44 pub common_fields: CommonFields<'a, NoFlags>,
45 #[serde(rename = "VaultID")]
47 pub vault_id: Cow<'a, str>,
48 pub holder: Cow<'a, str>,
50 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 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, sequence,
149 signers,
150 None, source_tag,
152 ticket_sequence,
153 None, ),
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 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 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 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 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}