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#[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 #[serde(flatten)]
46 pub common_fields: CommonFields<'a, NoFlags>,
47 pub amount: Amount<'a>,
51 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 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(), )),
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}