Skip to main content

xrpl/models/transactions/
vault_withdraw.rs

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/// Withdraw assets from a vault on the XRP Ledger (XLS-65).
16///
17/// The withdrawer burns share tokens (MPTokens) in exchange for the
18/// proportional share of the vault's assets.
19///
20/// See VaultWithdraw transaction:
21/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0065d-single-asset-vault>`
22#[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    /// The base fields for all transaction models.
36    ///
37    /// See Transaction Common Fields:
38    /// `<https://xrpl.org/transaction-common-fields.html>`
39    #[serde(flatten)]
40    pub common_fields: CommonFields<'a, NoFlags>,
41    /// The ID of the vault to withdraw from (256-bit hex string).
42    #[serde(rename = "VaultID")]
43    pub vault_id: Cow<'a, str>,
44    /// The amount of the asset to withdraw from the vault.
45    pub amount: Amount<'a>,
46    /// An account to receive the withdrawn assets. Must be able to receive the asset.
47    pub destination: Option<Cow<'a, str>>,
48    /// Arbitrary tag identifying the reason for the withdrawal to the destination.
49    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    /// Set the destination account.
135    pub fn with_destination(mut self, destination: Cow<'a, str>) -> Self {
136        self.destination = Some(destination);
137        self
138    }
139
140    /// Set the destination tag.
141    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        // Serialize
172        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        // Deserialize
179        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}