Skip to main content

xrpl/models/ledger/objects/
mptoken.rs

1use alloc::borrow::Cow;
2
3use serde::{Deserialize, Serialize};
4use serde_repr::{Deserialize_repr, Serialize_repr};
5use serde_with::skip_serializing_none;
6use strum_macros::{AsRefStr, Display, EnumIter};
7
8use crate::models::{ledger::objects::LedgerEntryType, Model, XRPLModelException, XRPLModelResult};
9
10use super::{CommonFields, LedgerObject};
11
12/// Ledger-object flags for the `MPToken` object.
13///
14/// See `MPToken` flags:
15/// `<https://xrpl.org/docs/references/protocol/ledger-data/ledger-entry-types/mptoken>`
16#[derive(
17    Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter,
18)]
19#[repr(u32)]
20pub enum MPTokenFlag {
21    /// This holder's MPToken balance is locked.
22    LsfMPTLocked = 0x0001,
23    /// This holder is authorized to hold the MPT. Set when the issuer
24    /// authorizes the holder via `MPTokenAuthorize`.
25    LsfMPTAuthorized = 0x0002,
26    /// This MPToken is held by an AMM account. Set by the protocol;
27    /// matches `lsfMPTAMM` in rippled `LedgerFormats.h`.
28    LsfMPTAMM = 0x0004,
29}
30
31/// The `MPToken` ledger object represents a single account's holdings of a
32/// specific Multi-Purpose Token issuance.
33///
34/// `<https://xrpl.org/docs/references/protocol/ledger-data/ledger-entry-types/mptoken>`
35#[skip_serializing_none]
36#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
37#[serde(rename_all = "PascalCase")]
38pub struct MPToken<'a> {
39    /// The base fields for all ledger object models.
40    #[serde(flatten)]
41    pub common_fields: CommonFields<'a, MPTokenFlag>,
42    /// The owner (holder) of these MPTs.
43    pub account: Cow<'a, str>,
44    /// The `MPTokenIssuance` identifier.
45    #[serde(rename = "MPTokenIssuanceID")]
46    pub mptoken_issuance_id: Cow<'a, str>,
47    /// The amount of tokens currently held by the owner. The minimum is 0
48    /// and the maximum is 2^63-1.
49    #[serde(rename = "MPTAmount")]
50    pub mpt_amount: Cow<'a, str>,
51    /// The identifying hash of the transaction that most recently modified
52    /// this entry.
53    #[serde(rename = "PreviousTxnID")]
54    pub previous_txn_id: Cow<'a, str>,
55    /// The index of the ledger that contains the transaction that most
56    /// recently modified this object.
57    pub previous_txn_lgr_seq: u32,
58    /// A hint indicating which page of the owner directory links to this
59    /// entry, in case the directory consists of multiple pages.
60    pub owner_node: Option<Cow<'a, str>>,
61    /// The amount of this MPT currently locked in escrow or by other
62    /// mechanisms. Present only when the TokenEscrow amendment is active.
63    pub locked_amount: Option<Cow<'a, str>>,
64    /// `CB_IN` — the holder's confidential inbox balance: a 66-byte EC-ElGamal
65    /// ciphertext accumulating incoming confidential transfers until the
66    /// holder merges them with `ConfidentialMPTMergeInbox` (XLS-0096).
67    pub confidential_balance_inbox: Option<Cow<'a, str>>,
68    /// `CB_S` — the holder's confidential spending balance, as a 66-byte
69    /// EC-ElGamal ciphertext. Proofs are generated against this balance.
70    pub confidential_balance_spending: Option<Cow<'a, str>>,
71    /// Monotonic counter bumped whenever `CB_S` changes, binding proofs to a
72    /// specific balance state (folded into the transaction's context hash).
73    pub confidential_balance_version: Option<u32>,
74    /// The issuer's mirror of this holder's confidential balance, encrypted
75    /// under the issuance's `IssuerEncryptionKey`.
76    pub issuer_encrypted_balance: Option<Cow<'a, str>>,
77    /// The auditor's mirror of this holder's confidential balance, encrypted
78    /// under the issuance's `AuditorEncryptionKey`, when one is registered.
79    pub auditor_encrypted_balance: Option<Cow<'a, str>>,
80    /// The holder's 33-byte compressed EC-ElGamal public key, registered by
81    /// the holder's first `ConfidentialMPTConvert`.
82    pub holder_encryption_key: Option<Cow<'a, str>>,
83}
84
85impl<'a> Model for MPToken<'a> {
86    fn get_errors(&self) -> XRPLModelResult<()> {
87        if self.common_fields.index.is_none() && self.common_fields.ledger_index.is_none() {
88            return Err(XRPLModelException::MissingField(
89                "index or ledger_index".into(),
90            ));
91        }
92        Ok(())
93    }
94}
95
96impl<'a> LedgerObject<MPTokenFlag> for MPToken<'a> {
97    fn get_ledger_entry_type(&self) -> LedgerEntryType {
98        self.common_fields.get_ledger_entry_type()
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use alloc::borrow::Cow;
105    use alloc::vec;
106
107    use crate::models::FlagCollection;
108
109    use super::*;
110    use crate::utils::testing::test_constants::*;
111
112    #[test]
113    fn test_serde() {
114        let mptoken = MPToken {
115            common_fields: CommonFields {
116                flags: FlagCollection(vec![MPTokenFlag::LsfMPTAuthorized]),
117                ledger_entry_type: LedgerEntryType::MPToken,
118                index: Some(Cow::from(
119                    "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769",
120                )),
121                ledger_index: None,
122            },
123            account: ACCOUNT_GENESIS.into(),
124            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
125            mpt_amount: "1000".into(),
126            previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879"
127                .into(),
128            previous_txn_lgr_seq: 123456,
129            owner_node: Some("0".into()),
130            locked_amount: None,
131            confidential_balance_inbox: None,
132            confidential_balance_spending: None,
133            confidential_balance_version: None,
134            issuer_encrypted_balance: None,
135            auditor_encrypted_balance: None,
136            holder_encryption_key: None,
137        };
138
139        let serialized = serde_json::to_string(&mptoken).unwrap();
140        let deserialized: MPToken = serde_json::from_str(&serialized).unwrap();
141        assert_eq!(mptoken, deserialized);
142    }
143
144    #[test]
145    fn test_serde_with_locked_amount() {
146        let mptoken = MPToken {
147            common_fields: CommonFields {
148                flags: FlagCollection(vec![]),
149                ledger_entry_type: LedgerEntryType::MPToken,
150                index: Some(Cow::from(
151                    "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769",
152                )),
153                ledger_index: None,
154            },
155            account: ACCOUNT_GENESIS.into(),
156            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
157            mpt_amount: "500".into(),
158            previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879"
159                .into(),
160            previous_txn_lgr_seq: 123456,
161            owner_node: None,
162            locked_amount: Some("250".into()),
163            confidential_balance_inbox: None,
164            confidential_balance_spending: None,
165            confidential_balance_version: None,
166            issuer_encrypted_balance: None,
167            auditor_encrypted_balance: None,
168            holder_encryption_key: None,
169        };
170
171        let serialized = serde_json::to_string(&mptoken).unwrap();
172        assert!(
173            serialized.contains("\"LockedAmount\":\"250\""),
174            "LockedAmount must serialize as PascalCase: {serialized}"
175        );
176        let deserialized: MPToken = serde_json::from_str(&serialized).unwrap();
177        assert_eq!(mptoken, deserialized);
178    }
179
180    #[test]
181    fn test_missing_index_and_ledger_index_error() {
182        let mptoken = MPToken {
183            common_fields: CommonFields {
184                flags: FlagCollection(vec![]),
185                ledger_entry_type: LedgerEntryType::MPToken,
186                index: None,
187                ledger_index: None,
188            },
189            account: ACCOUNT_GENESIS.into(),
190            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
191            mpt_amount: "0".into(),
192            previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879"
193                .into(),
194            previous_txn_lgr_seq: 0,
195            owner_node: None,
196            locked_amount: None,
197            confidential_balance_inbox: None,
198            confidential_balance_spending: None,
199            confidential_balance_version: None,
200            issuer_encrypted_balance: None,
201            auditor_encrypted_balance: None,
202            holder_encryption_key: None,
203        };
204
205        assert!(mptoken.validate().is_err());
206    }
207
208    #[test]
209    fn test_validate_ok() {
210        let mptoken = MPToken {
211            common_fields: CommonFields {
212                flags: FlagCollection(vec![]),
213                ledger_entry_type: LedgerEntryType::MPToken,
214                index: Some(Cow::from(
215                    "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769",
216                )),
217                ledger_index: None,
218            },
219            account: ACCOUNT_GENESIS.into(),
220            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
221            mpt_amount: "0".into(),
222            previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879"
223                .into(),
224            previous_txn_lgr_seq: 0,
225            owner_node: None,
226            locked_amount: None,
227            confidential_balance_inbox: None,
228            confidential_balance_spending: None,
229            confidential_balance_version: None,
230            issuer_encrypted_balance: None,
231            auditor_encrypted_balance: None,
232            holder_encryption_key: None,
233        };
234        assert!(mptoken.validate().is_ok());
235    }
236
237    #[test]
238    fn test_ledger_entry_type() {
239        let mptoken = MPToken {
240            common_fields: CommonFields {
241                flags: FlagCollection(vec![]),
242                ledger_entry_type: LedgerEntryType::MPToken,
243                index: Some(Cow::from(
244                    "CF9421C5E0A80C7BC5F52A3566CCBD2E8F14C3DA1E65F3F3AB1EC5B5A3BDFEA",
245                )),
246                ledger_index: Some(Cow::from("5000000")),
247            },
248            account: ACCOUNT_GENESIS.into(),
249            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(),
250            mpt_amount: "0".into(),
251            previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879"
252                .into(),
253            previous_txn_lgr_seq: 4999998,
254            owner_node: None,
255            locked_amount: None,
256            confidential_balance_inbox: None,
257            confidential_balance_spending: None,
258            confidential_balance_version: None,
259            issuer_encrypted_balance: None,
260            auditor_encrypted_balance: None,
261            holder_encryption_key: None,
262        };
263
264        assert_eq!(mptoken.get_ledger_entry_type(), LedgerEntryType::MPToken);
265    }
266
267    #[test]
268    fn test_lsf_mpt_amm_round_trip() {
269        // An on-ledger MPToken held by an AMM has Flags: 4 (lsfMPTAMM).
270        // Deserializing must produce LsfMPTAMM; reserializing must restore Flags: 4.
271        // Prior to adding LsfMPTAMM the variant was unknown and silently became 0.
272        let json = r#"{
273            "LedgerEntryType": "MPToken",
274            "Flags": 4,
275            "index": "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769",
276            "Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
277            "MPTokenIssuanceID": "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58",
278            "MPTAmount": "0",
279            "PreviousTxnID": "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879",
280            "PreviousTxnLgrSeq": 0
281        }"#;
282
283        let mptoken: MPToken = serde_json::from_str(json).unwrap();
284        assert!(
285            mptoken
286                .common_fields
287                .flags
288                .0
289                .contains(&MPTokenFlag::LsfMPTAMM),
290            "expected LsfMPTAMM in flags, got {:?}",
291            mptoken.common_fields.flags
292        );
293
294        // Round-trip: reserialize and confirm Flags is 4
295        let reserialized = serde_json::to_string(&mptoken).unwrap();
296        assert!(
297            reserialized.contains("\"Flags\":4"),
298            "expected Flags:4 after round-trip, got: {reserialized}"
299        );
300    }
301}