Skip to main content

xrpl/models/ledger/objects/
vault.rs

1use crate::models::ledger::objects::LedgerEntryType;
2use crate::models::{Currency, FlagCollection, Model};
3use alloc::borrow::Cow;
4use serde::{Deserialize, Serialize};
5use serde_repr::{Deserialize_repr, Serialize_repr};
6use serde_with::skip_serializing_none;
7use strum_macros::{AsRefStr, Display, EnumIter};
8
9use super::{CommonFields, LedgerObject};
10
11/// Flags for the `Vault` ledger object (XLS-65 SingleAssetVault).
12#[derive(
13    Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter,
14)]
15#[repr(u32)]
16pub enum VaultFlag {
17    /// The vault was created with the private flag set; only allowlisted
18    /// accounts may deposit into this vault.
19    LsfVaultPrivate = 0x00010000,
20}
21
22/// The `Vault` object type describes a single-asset vault instance (XLS-65).
23///
24/// All string fields use `Cow<'a, str>`. Vault objects are constructed by the
25/// server; callers should treat all fields as read-only.
26///
27/// Note: the ideal field type for server-read-only strings is `&'a str`
28/// (zero-copy, immutable). However, switching to `&'a str` with
29/// `#[serde(borrow)]` requires the `'de: 'a` lifetime constraint to propagate
30/// through the entire `LedgerEntry` → `BaseLedger` → `LedgerV1` chain, which
31/// affects all other ledger objects. A follow-up PR should migrate `Vault`,
32/// `AccountRoot`, `MPToken`, and `MPTokenIssuance` to `&'a str` together as
33/// part of a codebase-wide ledger-object cleanup.
34///
35/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0065-single-asset-vault>`
36#[skip_serializing_none]
37#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
38#[serde(rename_all = "PascalCase")]
39pub struct Vault<'a> {
40    /// The base fields for all ledger object models.
41    ///
42    /// See Ledger Object Common Fields:
43    /// `<https://xrpl.org/ledger-entry-common-fields.html>`
44    #[serde(flatten)]
45    pub common_fields: CommonFields<'a, VaultFlag>,
46    /// The account address of the Vault Owner. (SoeRequired)
47    pub owner: Cow<'a, str>,
48    /// The address of the Vault's pseudo-account. (SoeRequired)
49    pub account: Cow<'a, str>,
50    /// The asset of the vault (XRP, IOU or MPT). (SoeRequired)
51    pub asset: Currency<'a>,
52    /// The total value of the vault. (SoeDefault)
53    pub assets_total: Option<Cow<'a, str>>,
54    /// The asset amount that is available in the vault. (SoeDefault)
55    pub assets_available: Option<Cow<'a, str>>,
56    /// The maximum asset amount that can be held in the vault. Zero means no cap. (SoeOptional)
57    pub assets_maximum: Option<Cow<'a, str>>,
58    /// The potential loss amount that is not yet realized, expressed as the vault's asset. (SoeDefault)
59    pub loss_unrealized: Option<Cow<'a, str>>,
60    /// The identifier of the share MPTokenIssuance object. (SoeRequired)
61    #[serde(rename = "ShareMPTID")]
62    pub share_mpt_id: Cow<'a, str>,
63    /// Indicates the withdrawal strategy used by the Vault. (SoeRequired)
64    pub withdrawal_policy: u8,
65    /// The Scale specifies the power of 10 to multiply an asset's value by
66    /// when converting it into an integer-based number of shares. (SoeDefault)
67    pub scale: Option<u8>,
68    /// The transaction sequence number that created the vault. (SoeRequired)
69    pub sequence: u32,
70    /// Arbitrary metadata about the Vault. Limited to 256 bytes. (SoeOptional)
71    pub data: Option<Cow<'a, str>>,
72    /// A hint indicating which page of the owner's directory links to this object. (SoeRequired)
73    pub owner_node: Cow<'a, str>,
74    /// The identifying hash of the transaction that most recently modified this object.
75    #[serde(rename = "PreviousTxnID")]
76    pub previous_txn_id: Cow<'a, str>,
77    /// The index of the ledger that contains the transaction that most recently modified
78    /// this object.
79    pub previous_txn_lgr_seq: u32,
80}
81
82impl<'a> Model for Vault<'a> {}
83
84impl<'a> LedgerObject<VaultFlag> for Vault<'a> {
85    fn get_ledger_entry_type(&self) -> LedgerEntryType {
86        self.common_fields.get_ledger_entry_type()
87    }
88}
89
90impl<'a> Vault<'a> {
91    /// Create a new `Vault` with required fields; optional fields default to `None`.
92    #[allow(clippy::too_many_arguments)]
93    pub fn new(
94        flags: FlagCollection<VaultFlag>,
95        index: Option<Cow<'a, str>>,
96        owner: Cow<'a, str>,
97        account: Cow<'a, str>,
98        asset: Currency<'a>,
99        share_mpt_id: Cow<'a, str>,
100        withdrawal_policy: u8,
101        sequence: u32,
102        owner_node: Cow<'a, str>,
103        previous_txn_id: Cow<'a, str>,
104        previous_txn_lgr_seq: u32,
105    ) -> Self {
106        Self {
107            common_fields: CommonFields {
108                flags,
109                ledger_entry_type: LedgerEntryType::Vault,
110                index,
111                ledger_index: None,
112            },
113            owner,
114            account,
115            asset,
116            assets_total: None,
117            assets_available: None,
118            assets_maximum: None,
119            loss_unrealized: None,
120            share_mpt_id,
121            withdrawal_policy,
122            scale: None,
123            sequence,
124            data: None,
125            owner_node,
126            previous_txn_id,
127            previous_txn_lgr_seq,
128        }
129    }
130}
131
132#[cfg(test)]
133mod test_serde {
134    use crate::models::currency::{Currency, IssuedCurrency};
135    use crate::models::ledger::objects::vault::{Vault, VaultFlag};
136    use crate::models::ledger::objects::CommonFields;
137    use crate::models::ledger::objects::LedgerEntryType;
138    use crate::models::FlagCollection;
139    use alloc::borrow::Cow;
140    use alloc::vec;
141
142    fn make_vault<'a>(
143        index: Option<Cow<'a, str>>,
144        owner: Cow<'a, str>,
145        account: Cow<'a, str>,
146        asset: Currency<'a>,
147        share_mpt_id: Cow<'a, str>,
148        withdrawal_policy: u8,
149        sequence: u32,
150        owner_node: Cow<'a, str>,
151        previous_txn_id: Cow<'a, str>,
152        previous_txn_lgr_seq: u32,
153    ) -> Vault<'a> {
154        Vault::new(
155            FlagCollection::<VaultFlag>::default(),
156            index,
157            owner,
158            account,
159            asset,
160            share_mpt_id,
161            withdrawal_policy,
162            sequence,
163            owner_node,
164            previous_txn_id,
165            previous_txn_lgr_seq,
166        )
167    }
168
169    #[test]
170    fn test_serialize() {
171        let vault = Vault {
172            common_fields: CommonFields {
173                flags: FlagCollection::<VaultFlag>::default(),
174                ledger_entry_type: LedgerEntryType::Vault,
175                index: Some(Cow::from("ForTest")),
176                ledger_index: None,
177            },
178            owner: "rVaultOwner123".into(),
179            account: "rPseudoAccount456".into(),
180            asset: Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())),
181            assets_total: Some("1000000".into()),
182            assets_available: Some("800000".into()),
183            assets_maximum: Some("5000000".into()),
184            loss_unrealized: Some("0".into()),
185            share_mpt_id: "00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into(),
186            withdrawal_policy: 1,
187            scale: Some(6),
188            sequence: 5,
189            data: Some("48656C6C6F".into()),
190            owner_node: "0".into(),
191            previous_txn_id: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"
192                .into(),
193            previous_txn_lgr_seq: 12345678,
194        };
195
196        let serialized = serde_json::to_string(&vault).unwrap();
197        let deserialized: Vault = serde_json::from_str(&serialized).unwrap();
198        assert_eq!(vault, deserialized);
199    }
200
201    #[test]
202    fn test_minimal_vault() {
203        let vault = make_vault(
204            Some(Cow::from("MinimalTest")),
205            "rMinimalOwner789".into(),
206            "rMinimalPseudo789".into(),
207            Currency::IssuedCurrency(IssuedCurrency::new("EUR".into(), "rEURIssuer012".into())),
208            "00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into(),
209            1,
210            1,
211            "0".into(),
212            "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF".into(),
213            1,
214        );
215
216        let serialized = serde_json::to_string(&vault).unwrap();
217        let deserialized: Vault = serde_json::from_str(&serialized).unwrap();
218        assert_eq!(vault, deserialized);
219    }
220
221    #[test]
222    fn test_vault_with_all_fields() {
223        let vault = Vault {
224            common_fields: CommonFields {
225                flags: FlagCollection::<VaultFlag>::default(),
226                ledger_entry_type: LedgerEntryType::Vault,
227                index: Some(Cow::from("FullVaultTest")),
228                ledger_index: Some(Cow::from("ledger_idx_123")),
229            },
230            owner: "rFullVaultOwner456".into(),
231            account: "rFullPseudoAccount".into(),
232            asset: Currency::IssuedCurrency(IssuedCurrency::new(
233                "BTC".into(),
234                "rBTCIssuer789".into(),
235            )),
236            assets_total: Some("50000000".into()),
237            assets_available: Some("45000000".into()),
238            assets_maximum: Some("100000000".into()),
239            loss_unrealized: Some("200000".into()),
240            share_mpt_id: "00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into(),
241            withdrawal_policy: 1,
242            scale: Some(6),
243            sequence: 1,
244            data: Some("44617461".into()),
245            owner_node: "42".into(),
246            previous_txn_id: "FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321"
247                .into(),
248            previous_txn_lgr_seq: 99999999,
249        };
250
251        let serialized = serde_json::to_string(&vault).unwrap();
252        let deserialized: Vault = serde_json::from_str(&serialized).unwrap();
253        assert_eq!(vault, deserialized);
254    }
255
256    #[test]
257    fn test_new_constructor() {
258        let vault = Vault::new(
259            FlagCollection::<VaultFlag>::default(),
260            Some(Cow::from("NewConstructorTest")),
261            "rNewOwner".into(),
262            "rNewAccount".into(),
263            Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer".into())),
264            "00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into(),
265            1,
266            42,
267            "0".into(),
268            "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890".into(),
269            100,
270        );
271        let serialized = serde_json::to_string(&vault).unwrap();
272        let deserialized: Vault = serde_json::from_str(&serialized).unwrap();
273        assert_eq!(vault, deserialized);
274        assert!(vault.assets_total.is_none());
275        assert!(vault.data.is_none());
276    }
277
278    #[test]
279    fn test_vault_private_flag_serde() {
280        let vault = Vault {
281            common_fields: CommonFields {
282                flags: vec![VaultFlag::LsfVaultPrivate].into(),
283                ledger_entry_type: LedgerEntryType::Vault,
284                index: Some(Cow::from("PrivateFlagTest")),
285                ledger_index: None,
286            },
287            owner: "rFlagOwner".into(),
288            account: "rFlagAccount".into(),
289            asset: Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer".into())),
290            assets_total: None,
291            assets_available: None,
292            assets_maximum: None,
293            loss_unrealized: None,
294            share_mpt_id: "00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into(),
295            withdrawal_policy: 1,
296            scale: None,
297            sequence: 1,
298            data: None,
299            owner_node: "0".into(),
300            previous_txn_id: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"
301                .into(),
302            previous_txn_lgr_seq: 1,
303        };
304        let serialized = serde_json::to_string(&vault).unwrap();
305        let deserialized: Vault = serde_json::from_str(&serialized).unwrap();
306        assert_eq!(vault, deserialized);
307        // Flag value 0x00010000 = 65536 should appear in the JSON
308        assert!(
309            serialized.contains("65536"),
310            "expected LsfVaultPrivate flag value 65536 in JSON: {serialized}"
311        );
312    }
313
314    #[test]
315    fn test_serialized_keys_are_pascal_case() {
316        let vault = Vault {
317            common_fields: CommonFields {
318                flags: FlagCollection::<VaultFlag>::default(),
319                ledger_entry_type: LedgerEntryType::Vault,
320                index: Some(Cow::from("KeysTest")),
321                ledger_index: None,
322            },
323            owner: "rKeysOwner".into(),
324            account: "rKeysAccount".into(),
325            asset: Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuerX".into())),
326            assets_total: Some("100".into()),
327            assets_available: Some("90".into()),
328            assets_maximum: Some("200".into()),
329            loss_unrealized: Some("5".into()),
330            share_mpt_id: "00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into(),
331            withdrawal_policy: 1,
332            scale: Some(6),
333            sequence: 1,
334            data: Some("48656C6C6F".into()),
335            owner_node: "0".into(),
336            previous_txn_id: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"
337                .into(),
338            previous_txn_lgr_seq: 100,
339        };
340
341        let json = serde_json::to_string(&vault).unwrap();
342        assert!(json.contains("\"Account\""), "missing Account key: {json}");
343        assert!(json.contains("\"Owner\""), "missing Owner key: {json}");
344        assert!(json.contains("\"Asset\""), "missing Asset key: {json}");
345        assert!(
346            json.contains("\"AssetsTotal\""),
347            "missing AssetsTotal key: {json}"
348        );
349        assert!(
350            json.contains("\"AssetsAvailable\""),
351            "missing AssetsAvailable key: {json}"
352        );
353        assert!(
354            json.contains("\"AssetsMaximum\""),
355            "missing AssetsMaximum key: {json}"
356        );
357        assert!(
358            json.contains("\"LossUnrealized\""),
359            "missing LossUnrealized key: {json}"
360        );
361        assert!(
362            json.contains("\"ShareMPTID\""),
363            "missing ShareMPTID key: {json}"
364        );
365        assert!(
366            json.contains("\"WithdrawalPolicy\""),
367            "missing WithdrawalPolicy key: {json}"
368        );
369        assert!(json.contains("\"Scale\""), "missing Scale key: {json}");
370        assert!(
371            json.contains("\"Sequence\""),
372            "missing Sequence key: {json}"
373        );
374        assert!(json.contains("\"Data\""), "missing Data key: {json}");
375        assert!(
376            json.contains("\"OwnerNode\""),
377            "missing OwnerNode key: {json}"
378        );
379        assert!(
380            json.contains("\"PreviousTxnID\""),
381            "missing PreviousTxnID key: {json}"
382        );
383        assert!(
384            json.contains("\"PreviousTxnLgrSeq\""),
385            "missing PreviousTxnLgrSeq key: {json}"
386        );
387        assert!(
388            json.contains("\"LedgerEntryType\":\"Vault\""),
389            "missing LedgerEntryType=Vault: {json}"
390        );
391    }
392}