1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
use alloc::borrow::Cow;
use serde::{Deserialize, Serialize};
use crate::models::ledger::objects::vault::Vault;
/// The share token (`MPTokenIssuance`) embedded in a `vault_info` response.
///
/// xrpld nests the vault's share `MPTokenIssuance` inside the `vault` object
/// under the lowercase `"shares"` key. All fields are `Option` to tolerate
/// server omissions; keys are PascalCase as returned by xrpld.
///
/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0033d>`
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "PascalCase")]
pub struct VaultShares<'a> {
/// Always `"MPTokenIssuance"` for this object.
pub ledger_entry_type: Option<Cow<'a, str>>,
pub flags: Option<u32>,
/// Account address of the MPTokenIssuance issuer (the vault pseudo-account).
pub issuer: Option<Cow<'a, str>>,
/// Total amount outstanding (string integer).
pub outstanding_amount: Option<Cow<'a, str>>,
/// Maximum amount that may ever be issued; absent means no cap.
pub maximum_amount: Option<Cow<'a, str>>,
/// Scale — power of ten multiplier for the asset value.
pub asset_scale: Option<u8>,
/// Transfer fee in basis-point units (0–50 000).
pub transfer_fee: Option<u16>,
/// Arbitrary metadata about the share token (hex-encoded).
#[serde(rename = "MPTokenMetadata")]
pub mptoken_metadata: Option<Cow<'a, str>>,
/// Sequence number of the transaction that created this issuance.
pub sequence: Option<u32>,
pub owner_node: Option<Cow<'a, str>>,
#[serde(rename = "PreviousTxnID")]
pub previous_txn_id: Option<Cow<'a, str>>,
pub previous_txn_lgr_seq: Option<u32>,
/// Computed ID of this `MPTokenIssuance` (48 hex chars); matches `Vault.ShareMPTID`.
/// xrpld includes this field on `MPTokenIssuance` objects in `account_objects` responses
/// and in the embedded `shares` subobject of `vault_info`.
#[serde(rename = "mpt_issuance_id")]
pub mpt_issuance_id: Option<Cow<'a, str>>,
}
/// The vault object as returned by `vault_info` (XLS-65 SingleAssetVault).
///
/// Wraps all on-ledger [`Vault`] fields (via `#[serde(flatten)]`) plus the
/// `shares` sub-object that `vault_info` appends, containing the vault's
/// share [`MPTokenIssuance`](VaultShares) data.
///
/// The flat `#[serde(flatten)]` means this struct serialises/deserialises
/// identically to the raw `Vault` ledger object extended with a `"shares"`
/// key — no extra nesting.
///
/// # Flatten chain
///
/// `VaultObject` flattens `Vault<'a>`, which itself flattens `CommonFields`.
/// This two-level chain is safe for JSON, but `#[serde(deny_unknown_fields)]`
/// must **not** be added to any struct in this chain — serde does not propagate
/// it through flattened structs and silently drops unknown fields instead.
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultObject<'a> {
/// All on-ledger `Vault` fields (Owner, Account, Asset, AssetsTotal, etc.).
#[serde(flatten)]
pub vault: Vault<'a>,
/// The share `MPTokenIssuance` appended by the `vault_info` RPC.
/// Present on every successful `vault_info` response; absent in raw
/// ledger-entry data.
pub shares: Option<VaultShares<'a>>,
}
/// Response from the `vault_info` method (XLS-65 SingleAssetVault).
///
/// xrpld returns the vault object under a `"vault"` key (not `"node"`).
/// The nested `"shares"` sub-object within `"vault"` is the vault's share
/// `MPTokenIssuance`, typed as [`VaultShares`].
///
/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0065d>`
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct VaultInfo<'a> {
/// The vault object with all on-ledger fields plus the share token.
pub vault: Option<VaultObject<'a>>,
/// The ledger sequence number current at request time (open-ledger mode).
pub ledger_current_index: Option<u32>,
/// The ledger index of the validated ledger used to retrieve this data.
pub ledger_index: Option<u32>,
/// Identifying hash of the ledger version used to retrieve this data.
pub ledger_hash: Option<Cow<'a, str>>,
/// Whether this data is from a validated ledger version.
pub validated: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Currency;
/// Minimal valid vault JSON (XRP asset, all Vault required fields present).
const VAULT_JSON: &str = r#"{
"vault": {
"LedgerEntryType": "Vault",
"Flags": 0,
"Owner": "rVaultOwner123",
"Account": "rPseudoAccount456",
"Asset": {"currency": "XRP"},
"WithdrawalPolicy": 1,
"Sequence": 5,
"ShareMPTID": "00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E",
"OwnerNode": "0",
"PreviousTxnID": "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890",
"PreviousTxnLgrSeq": 12345678,
"shares": {
"LedgerEntryType": "MPTokenIssuance",
"OutstandingAmount": "1000000"
}
},
"ledger_index": 1000,
"validated": true
}"#;
#[test]
fn test_vault_info_deserialize() {
let result: VaultInfo = serde_json::from_str(VAULT_JSON).unwrap();
assert_eq!(result.ledger_index, Some(1000));
assert_eq!(result.validated, Some(true));
let vault_obj = result.vault.unwrap();
assert_eq!(vault_obj.vault.owner.as_ref(), "rVaultOwner123");
assert_eq!(vault_obj.vault.account.as_ref(), "rPseudoAccount456");
assert_eq!(vault_obj.vault.withdrawal_policy, 1);
assert_eq!(vault_obj.vault.sequence, 5);
assert_eq!(
vault_obj.vault.share_mpt_id.as_ref(),
"00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E"
);
assert!(
matches!(vault_obj.vault.asset, Currency::XRP(_)),
"expected XRP asset, got {:?}",
vault_obj.vault.asset
);
let shares = vault_obj.shares.unwrap();
assert_eq!(shares.ledger_entry_type.as_deref(), Some("MPTokenIssuance"));
assert_eq!(shares.outstanding_amount.as_deref(), Some("1000000"));
}
#[test]
fn test_vault_info_round_trip() {
let info: VaultInfo = serde_json::from_str(VAULT_JSON).unwrap();
let serialized = serde_json::to_string(&info).unwrap();
let deserialized: VaultInfo = serde_json::from_str(&serialized).unwrap();
assert_eq!(info, deserialized);
assert!(
serialized.contains("\"vault\""),
"expected vault key: {serialized}"
);
assert!(
!serialized.contains("\"node\""),
"node key must be absent: {serialized}"
);
}
#[test]
fn test_vault_info_no_shares() {
let json = r#"{
"vault": {
"LedgerEntryType": "Vault",
"Flags": 0,
"Owner": "rOwner",
"Account": "rAccount",
"Asset": {"currency": "XRP"},
"WithdrawalPolicy": 1,
"Sequence": 1,
"ShareMPTID": "00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E",
"OwnerNode": "0",
"PreviousTxnID": "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890",
"PreviousTxnLgrSeq": 1
},
"ledger_index": 42,
"validated": true
}"#;
let result: VaultInfo = serde_json::from_str(json).unwrap();
let vault_obj = result.vault.unwrap();
assert!(vault_obj.shares.is_none(), "shares should be absent");
assert_eq!(vault_obj.vault.owner.as_ref(), "rOwner");
}
#[test]
fn test_vault_info_optional_fields_skipped() {
let info: VaultInfo = serde_json::from_str(VAULT_JSON).unwrap();
let serialized = serde_json::to_string(&info).unwrap();
// Optional fields not present in source JSON must not appear in output
assert!(
!serialized.contains("\"ledger_current_index\""),
"absent optional field must be omitted: {serialized}"
);
}
}