1use alloc::borrow::Cow;
2use alloc::vec::Vec;
3use serde::{Deserialize, Serialize};
4use serde_with::skip_serializing_none;
5
6use crate::core::addresscodec::is_valid_classic_address;
7use crate::models::amount::XRPAmount;
8use crate::models::{
9 Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelException, XRPLModelResult,
10};
11
12use super::vault_common::{validate_positive_amount, validate_vault_id};
13use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType};
14
15#[skip_serializing_none]
23#[derive(
24 Debug,
25 Default,
26 Serialize,
27 Deserialize,
28 PartialEq,
29 Eq,
30 Clone,
31 xrpl_rust_macros::ValidateCurrencies,
32)]
33#[serde(rename_all = "PascalCase")]
34pub struct VaultWithdraw<'a> {
35 #[serde(flatten)]
40 pub common_fields: CommonFields<'a, NoFlags>,
41 #[serde(rename = "VaultID")]
43 pub vault_id: Cow<'a, str>,
44 pub amount: Amount<'a>,
46 pub destination: Option<Cow<'a, str>>,
48 pub destination_tag: Option<u32>,
50}
51
52impl Model for VaultWithdraw<'_> {
53 fn get_errors(&self) -> XRPLModelResult<()> {
54 self.validate_currencies()?;
55 validate_vault_id(&self.vault_id)?;
56 validate_positive_amount("amount", &self.amount)?;
57 if let Some(dest) = &self.destination {
58 if !is_valid_classic_address(dest) {
59 return Err(XRPLModelException::InvalidValue {
60 field: "destination".into(),
61 expected: "a valid classic account address".into(),
62 found: dest.as_ref().into(),
63 });
64 }
65 }
66 Ok(())
67 }
68}
69
70impl<'a> Transaction<'a, NoFlags> for VaultWithdraw<'a> {
71 fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
72 &self.common_fields
73 }
74
75 fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
76 &mut self.common_fields
77 }
78
79 fn get_transaction_type(&self) -> &TransactionType {
80 self.common_fields.get_transaction_type()
81 }
82}
83
84impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultWithdraw<'a> {
85 fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
86 &mut self.common_fields
87 }
88
89 fn into_self(self) -> Self {
90 self
91 }
92}
93
94impl<'a> VaultWithdraw<'a> {
95 pub fn new(
96 account: Cow<'a, str>,
97 account_txn_id: Option<Cow<'a, str>>,
98 fee: Option<XRPAmount<'a>>,
99 last_ledger_sequence: Option<u32>,
100 memos: Option<Vec<Memo>>,
101 sequence: Option<u32>,
102 signers: Option<Vec<Signer>>,
103 source_tag: Option<u32>,
104 ticket_sequence: Option<u32>,
105 vault_id: Cow<'a, str>,
106 amount: Amount<'a>,
107 destination: Option<Cow<'a, str>>,
108 destination_tag: Option<u32>,
109 ) -> VaultWithdraw<'a> {
110 VaultWithdraw {
111 common_fields: CommonFields::new(
112 account,
113 TransactionType::VaultWithdraw,
114 account_txn_id,
115 fee,
116 Some(FlagCollection::default()),
117 last_ledger_sequence,
118 memos,
119 None,
120 sequence,
121 signers,
122 None,
123 source_tag,
124 ticket_sequence,
125 None,
126 ),
127 vault_id,
128 amount,
129 destination,
130 destination_tag,
131 }
132 }
133
134 pub fn with_destination(mut self, destination: Cow<'a, str>) -> Self {
136 self.destination = Some(destination);
137 self
138 }
139
140 pub fn with_destination_tag(mut self, destination_tag: u32) -> Self {
142 self.destination_tag = Some(destination_tag);
143 self
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::models::{IssuedCurrencyAmount, XRPAmount};
151
152 const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF";
153
154 #[test]
155 fn test_serde() {
156 let vault_withdraw = VaultWithdraw {
157 common_fields: CommonFields {
158 account: "rWithdrawer123".into(),
159 transaction_type: TransactionType::VaultWithdraw,
160 signing_pub_key: Some("".into()),
161 ..Default::default()
162 },
163 vault_id: VAULT_ID.into(),
164 amount: Amount::XRPAmount(XRPAmount::from("1000000")),
165 destination: None,
166 destination_tag: None,
167 };
168
169 let json_str = r#"{"Account":"rWithdrawer123","TransactionType":"VaultWithdraw","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Amount":"1000000"}"#;
170
171 let serialized = serde_json::to_string(&vault_withdraw).unwrap();
173 assert_eq!(
174 serde_json::to_value(&serialized).unwrap(),
175 serde_json::to_value(json_str).unwrap()
176 );
177
178 let deserialized: VaultWithdraw = serde_json::from_str(json_str).unwrap();
180 assert_eq!(vault_withdraw, deserialized);
181 }
182
183 #[test]
184 fn test_serde_issued_currency() {
185 let vault_withdraw = VaultWithdraw {
186 common_fields: CommonFields {
187 account: "rWithdrawICA456".into(),
188 transaction_type: TransactionType::VaultWithdraw,
189 signing_pub_key: Some("".into()),
190 ..Default::default()
191 },
192 vault_id: VAULT_ID.into(),
193 amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
194 "USD".into(),
195 "rIssuer789".into(),
196 "500".into(),
197 )),
198 destination: None,
199 destination_tag: None,
200 };
201
202 let serialized = serde_json::to_string(&vault_withdraw).unwrap();
203 let deserialized: VaultWithdraw = serde_json::from_str(&serialized).unwrap();
204 assert_eq!(vault_withdraw, deserialized);
205 }
206
207 #[test]
208 fn test_builder_pattern() {
209 let vault_withdraw = VaultWithdraw {
210 common_fields: CommonFields {
211 account: "rWithdrawer123".into(),
212 transaction_type: TransactionType::VaultWithdraw,
213 ..Default::default()
214 },
215 vault_id: VAULT_ID.into(),
216 amount: Amount::XRPAmount(XRPAmount::from("1000000")),
217 destination: None,
218 destination_tag: None,
219 }
220 .with_fee("12".into())
221 .with_sequence(100)
222 .with_last_ledger_sequence(7108682)
223 .with_source_tag(12345)
224 .with_memo(Memo {
225 memo_data: Some("withdrawing from vault".into()),
226 memo_format: None,
227 memo_type: Some("text".into()),
228 });
229
230 assert_eq!(vault_withdraw.vault_id, VAULT_ID);
231 assert_eq!(vault_withdraw.common_fields.fee.as_ref().unwrap().0, "12");
232 assert_eq!(vault_withdraw.common_fields.sequence, Some(100));
233 assert_eq!(
234 vault_withdraw.common_fields.last_ledger_sequence,
235 Some(7108682)
236 );
237 assert_eq!(vault_withdraw.common_fields.source_tag, Some(12345));
238 assert_eq!(
239 vault_withdraw.common_fields.memos.as_ref().unwrap().len(),
240 1
241 );
242 }
243
244 #[test]
245 fn test_default() {
246 let vault_withdraw = VaultWithdraw {
247 common_fields: CommonFields {
248 account: "rWithdrawer789".into(),
249 transaction_type: TransactionType::VaultWithdraw,
250 ..Default::default()
251 },
252 vault_id: VAULT_ID.into(),
253 amount: Amount::XRPAmount(XRPAmount::from("5000000")),
254 destination: None,
255 destination_tag: None,
256 };
257
258 assert_eq!(vault_withdraw.common_fields.account, "rWithdrawer789");
259 assert_eq!(
260 vault_withdraw.common_fields.transaction_type,
261 TransactionType::VaultWithdraw
262 );
263 assert_eq!(vault_withdraw.vault_id, VAULT_ID);
264 assert!(vault_withdraw.common_fields.fee.is_none());
265 assert!(vault_withdraw.common_fields.sequence.is_none());
266 }
267
268 #[test]
269 fn test_ticket_sequence() {
270 let ticket_withdraw = VaultWithdraw {
271 common_fields: CommonFields {
272 account: "rTicketWithdrawer111".into(),
273 transaction_type: TransactionType::VaultWithdraw,
274 ..Default::default()
275 },
276 vault_id: VAULT_ID.into(),
277 amount: Amount::XRPAmount(XRPAmount::from("2000000")),
278 destination: None,
279 destination_tag: None,
280 }
281 .with_ticket_sequence(54321)
282 .with_fee("12".into());
283
284 assert_eq!(ticket_withdraw.common_fields.ticket_sequence, Some(54321));
285 assert!(ticket_withdraw.common_fields.sequence.is_none());
286 }
287
288 #[test]
289 fn test_multiple_memos() {
290 let multi_memo_withdraw = VaultWithdraw {
291 common_fields: CommonFields {
292 account: "rMultiMemoWithdrawer222".into(),
293 transaction_type: TransactionType::VaultWithdraw,
294 ..Default::default()
295 },
296 vault_id: VAULT_ID.into(),
297 amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
298 "USD".into(),
299 "rUSDIssuer333".into(),
300 "250".into(),
301 )),
302 destination: None,
303 destination_tag: None,
304 }
305 .with_memo(Memo {
306 memo_data: Some("partial withdrawal".into()),
307 memo_format: None,
308 memo_type: Some("text".into()),
309 })
310 .with_memo(Memo {
311 memo_data: Some("rebalancing portfolio".into()),
312 memo_format: None,
313 memo_type: Some("text".into()),
314 })
315 .with_fee("18".into())
316 .with_sequence(400);
317
318 assert_eq!(
319 multi_memo_withdraw
320 .common_fields
321 .memos
322 .as_ref()
323 .unwrap()
324 .len(),
325 2
326 );
327 assert_eq!(multi_memo_withdraw.common_fields.sequence, Some(400));
328 }
329
330 #[test]
331 fn test_new_constructor() {
332 let vault_withdraw = VaultWithdraw {
333 common_fields: CommonFields {
334 account: "rNewWithdrawer444".into(),
335 transaction_type: TransactionType::VaultWithdraw,
336 fee: Some("12".into()),
337 last_ledger_sequence: Some(7108682),
338 sequence: Some(100),
339 ..Default::default()
340 },
341 vault_id: VAULT_ID.into(),
342 amount: Amount::XRPAmount(XRPAmount::from("10000000")),
343 destination: None,
344 destination_tag: None,
345 };
346
347 assert_eq!(vault_withdraw.common_fields.account, "rNewWithdrawer444");
348 assert_eq!(
349 vault_withdraw.common_fields.transaction_type,
350 TransactionType::VaultWithdraw
351 );
352 assert_eq!(vault_withdraw.common_fields.fee.as_ref().unwrap().0, "12");
353 assert_eq!(vault_withdraw.vault_id, VAULT_ID);
354 }
355
356 #[test]
357 fn test_with_destination() {
358 let vault_withdraw = VaultWithdraw {
359 common_fields: CommonFields {
360 account: "rWithdrawerDest".into(),
361 transaction_type: TransactionType::VaultWithdraw,
362 ..Default::default()
363 },
364 vault_id: VAULT_ID.into(),
365 amount: Amount::XRPAmount(XRPAmount::from("1000000")),
366 destination: None,
367 destination_tag: None,
368 }
369 .with_destination("rDestAccount789".into())
370 .with_destination_tag(42);
371
372 assert_eq!(vault_withdraw.destination, Some("rDestAccount789".into()));
373 assert_eq!(vault_withdraw.destination_tag, Some(42));
374 }
375
376 #[test]
377 fn test_invalid_destination_rejected() {
378 let vault_withdraw = VaultWithdraw {
379 common_fields: CommonFields {
380 account: "rWithdrawer".into(),
381 transaction_type: TransactionType::VaultWithdraw,
382 ..Default::default()
383 },
384 vault_id: VAULT_ID.into(),
385 amount: Amount::XRPAmount(XRPAmount::from("1000000")),
386 destination: Some("notanaddress".into()),
387 destination_tag: None,
388 };
389 assert!(vault_withdraw.validate().is_err());
390 }
391
392 #[test]
393 fn test_valid_destination_accepted() {
394 let vault_withdraw = VaultWithdraw {
395 common_fields: CommonFields {
396 account: "rWithdrawer".into(),
397 transaction_type: TransactionType::VaultWithdraw,
398 ..Default::default()
399 },
400 vault_id: VAULT_ID.into(),
401 amount: Amount::XRPAmount(XRPAmount::from("1000000")),
402 destination: Some("rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into()),
403 destination_tag: None,
404 };
405 assert!(vault_withdraw.validate().is_ok());
406 }
407
408 #[test]
409 fn test_get_transaction_type() {
410 use crate::models::transactions::Transaction;
411 let vault_withdraw = VaultWithdraw {
412 common_fields: CommonFields {
413 account: "rTxTypeTest".into(),
414 transaction_type: TransactionType::VaultWithdraw,
415 ..Default::default()
416 },
417 vault_id: VAULT_ID.into(),
418 amount: Amount::XRPAmount(XRPAmount::from("1000000")),
419 destination: None,
420 destination_tag: None,
421 };
422 assert_eq!(
423 *vault_withdraw.get_transaction_type(),
424 TransactionType::VaultWithdraw
425 );
426 }
427
428 #[test]
429 fn test_validate() {
430 let vault_withdraw = VaultWithdraw {
431 common_fields: CommonFields {
432 account: "rValidateWithdrawer555".into(),
433 transaction_type: TransactionType::VaultWithdraw,
434 ..Default::default()
435 },
436 vault_id: VAULT_ID.into(),
437 amount: Amount::XRPAmount(XRPAmount::from("1000000")),
438 destination: None,
439 destination_tag: None,
440 }
441 .with_fee("12".into())
442 .with_sequence(300);
443
444 assert!(vault_withdraw.validate().is_ok());
445 }
446
447 #[test]
448 fn test_amount_zero_rejected() {
449 let vault_withdraw = VaultWithdraw {
450 common_fields: CommonFields {
451 account: "rWithdrawer".into(),
452 transaction_type: TransactionType::VaultWithdraw,
453 ..Default::default()
454 },
455 vault_id: VAULT_ID.into(),
456 amount: Amount::XRPAmount(XRPAmount::from("0")),
457 destination: None,
458 destination_tag: None,
459 };
460 assert!(vault_withdraw.validate().is_err());
461 }
462
463 #[test]
464 fn test_amount_negative_rejected() {
465 let vault_withdraw = VaultWithdraw {
466 common_fields: CommonFields {
467 account: "rWithdrawer".into(),
468 transaction_type: TransactionType::VaultWithdraw,
469 ..Default::default()
470 },
471 vault_id: VAULT_ID.into(),
472 amount: Amount::IssuedCurrencyAmount(crate::models::amount::IssuedCurrencyAmount::new(
473 "USD".into(),
474 "rIssuer".into(),
475 "-5".into(),
476 )),
477 destination: None,
478 destination_tag: None,
479 };
480 assert!(vault_withdraw.validate().is_err());
481 }
482
483 #[test]
484 fn test_amount_non_numeric_rejected() {
485 let vault_withdraw = VaultWithdraw {
486 common_fields: CommonFields {
487 account: "rWithdrawer".into(),
488 transaction_type: TransactionType::VaultWithdraw,
489 ..Default::default()
490 },
491 vault_id: VAULT_ID.into(),
492 amount: Amount::XRPAmount(XRPAmount::from("bad")),
493 destination: None,
494 destination_tag: None,
495 };
496 assert!(vault_withdraw.validate().is_err());
497 }
498
499 #[test]
500 fn test_amount_ica_non_numeric_rejected() {
501 let vault_withdraw = VaultWithdraw {
502 common_fields: CommonFields {
503 account: "rWithdrawer".into(),
504 transaction_type: TransactionType::VaultWithdraw,
505 ..Default::default()
506 },
507 vault_id: VAULT_ID.into(),
508 amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new(
509 "USD".into(),
510 "rIssuer".into(),
511 "not-a-number".into(),
512 )),
513 destination: None,
514 destination_tag: None,
515 };
516 assert!(vault_withdraw.validate().is_err());
517 }
518
519 #[test]
520 fn test_amount_mpt_positive_accepted() {
521 use crate::models::amount::MPTAmount;
522 let vault_withdraw = VaultWithdraw {
523 common_fields: CommonFields {
524 account: "rWithdrawer".into(),
525 transaction_type: TransactionType::VaultWithdraw,
526 ..Default::default()
527 },
528 vault_id: VAULT_ID.into(),
529 amount: Amount::MPTAmount(MPTAmount {
530 mpt_issuance_id: "000000016B4E90A4B36D74F6E16A5BED41EBD7AA37B19B89".into(),
531 value: "1000".into(),
532 }),
533 destination: None,
534 destination_tag: None,
535 };
536 assert!(vault_withdraw.validate().is_ok());
537 }
538
539 #[test]
540 fn test_amount_mpt_zero_rejected() {
541 use crate::models::amount::MPTAmount;
542 let vault_withdraw = VaultWithdraw {
543 common_fields: CommonFields {
544 account: "rWithdrawer".into(),
545 transaction_type: TransactionType::VaultWithdraw,
546 ..Default::default()
547 },
548 vault_id: VAULT_ID.into(),
549 amount: Amount::MPTAmount(MPTAmount {
550 mpt_issuance_id: "000000016B4E90A4B36D74F6E16A5BED41EBD7AA37B19B89".into(),
551 value: "0".into(),
552 }),
553 destination: None,
554 destination_tag: None,
555 };
556 assert!(vault_withdraw.validate().is_err());
557 }
558}