Skip to main content

xrpl/models/transactions/
confidential_mpt_convert.rs

1use alloc::borrow::Cow;
2use alloc::vec::Vec;
3use serde::{Deserialize, Serialize};
4use serde_with::skip_serializing_none;
5
6use crate::models::amount::XRPAmount;
7use crate::models::{
8    transactions::{Memo, Signer, Transaction, TransactionType},
9    Model, ValidateCurrencies, XRPLModelException,
10};
11use crate::models::{FlagCollection, NoFlags};
12
13use super::confidential_mpt_constants::{
14    address_is_issuer, validate_hex_length, validate_mpt_amount, BLINDING_FACTOR_LENGTH,
15    CIPHERTEXT_LENGTH, ENCRYPTION_KEY_LENGTH, SCHNORR_PROOF_LENGTH,
16};
17use super::mptoken_issuance_set::validate_mptoken_issuance_id;
18use super::{CommonFields, CommonTransactionBuilder};
19
20/// A `ConfidentialMPTConvert` transaction converts a holder's public MPT
21/// balance into confidential form (XLS-0096 §7).
22///
23/// On first use it also serves as the **opt-in** for confidential MPTs: the
24/// holder registers their `HolderEncryptionKey` and provides a 64-byte
25/// Schnorr Proof of Knowledge of the corresponding secret key.
26///
27/// On subsequent calls (key already registered) `holder_encryption_key`
28/// and `zk_proof` MUST both be absent — those fields are gated by §7.3.1
29/// rules 2 and 3.
30#[skip_serializing_none]
31#[derive(
32    Debug,
33    Default,
34    Serialize,
35    Deserialize,
36    PartialEq,
37    Eq,
38    Clone,
39    xrpl_rust_macros::ValidateCurrencies,
40)]
41#[serde(rename_all = "PascalCase")]
42pub struct ConfidentialMPTConvert<'a> {
43    #[serde(flatten)]
44    pub common_fields: CommonFields<'a, NoFlags>,
45
46    /// 24-byte `MPTokenIssuanceID` of the target MPT.
47    #[serde(rename = "MPTokenIssuanceID")]
48    pub mptoken_issuance_id: Cow<'a, str>,
49
50    /// Plaintext amount being converted from public to confidential.
51    /// Encoded as a u64 string per XRPL's large-integer convention.
52    #[serde(rename = "MPTAmount")]
53    pub mpt_amount: Cow<'a, str>,
54
55    /// 66-byte ElGamal ciphertext credited to the holder's `CB_IN`.
56    pub holder_encrypted_amount: Cow<'a, str>,
57
58    /// 66-byte ElGamal ciphertext credited to the issuer's mirror balance.
59    pub issuer_encrypted_amount: Cow<'a, str>,
60
61    /// 32-byte ElGamal randomness `r`. Revealed plaintext so validators
62    /// can deterministically verify the ciphertexts encrypt `mpt_amount`.
63    pub blinding_factor: Cow<'a, str>,
64
65    /// 33-byte compressed holder ElGamal public key. **Required** on first
66    /// Convert (key registration); **forbidden** thereafter.
67    pub holder_encryption_key: Option<Cow<'a, str>>,
68
69    /// 66-byte ElGamal ciphertext for the auditor mirror. Required iff the
70    /// issuance has an `AuditorEncryptionKey` registered.
71    pub auditor_encrypted_amount: Option<Cow<'a, str>>,
72
73    /// 64-byte Schnorr Proof of Knowledge of the holder's secret key.
74    /// **Required** if `holder_encryption_key` is present; **forbidden**
75    /// otherwise.
76    #[serde(rename = "ZKProof")]
77    pub zk_proof: Option<Cow<'a, str>>,
78}
79
80impl<'a> Model for ConfidentialMPTConvert<'a> {
81    fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
82        self._get_registration_error()?;
83        self._get_field_length_errors()?;
84        self._get_issuer_role_error()?;
85        self.validate_currencies()
86    }
87}
88
89impl<'a> ConfidentialMPTConvert<'a> {
90    /// `HolderEncryptionKey` and the Schnorr `ZKProof` are all-or-nothing:
91    /// both present on the registering (first) Convert, both absent after
92    /// (XLS-0096 §7.3.1 rules 2 and 3).
93    fn _get_registration_error(&self) -> crate::models::XRPLModelResult<()> {
94        match (
95            self.holder_encryption_key.is_some(),
96            self.zk_proof.is_some(),
97        ) {
98            (true, false) => Err(XRPLModelException::FieldRequiresField {
99                field1: "holder_encryption_key".into(),
100                field2: "zk_proof".into(),
101            }),
102            (false, true) => Err(XRPLModelException::FieldRequiresField {
103                field1: "zk_proof".into(),
104                field2: "holder_encryption_key".into(),
105            }),
106            _ => Ok(()),
107        }
108    }
109
110    /// The issuer converts value through its mirror balances, not a personal
111    /// confidential balance, so it cannot be the `Account` of a Convert
112    /// (`temMALFORMED`, `ConfidentialMPTConvert.cpp` preflight).
113    fn _get_issuer_role_error(&self) -> crate::models::XRPLModelResult<()> {
114        if address_is_issuer(
115            self.mptoken_issuance_id.as_ref(),
116            self.common_fields.account.as_ref(),
117        ) {
118            return Err(XRPLModelException::ValueEqualsValue {
119                field1: "account".into(),
120                field2: "issuer".into(),
121            });
122        }
123        Ok(())
124    }
125
126    fn _get_field_length_errors(&self) -> crate::models::XRPLModelResult<()> {
127        validate_mptoken_issuance_id(self.mptoken_issuance_id.as_ref())?;
128        // Unlike ConvertBack/Clawback, a zero amount is permitted here on
129        // purpose: rippled allows a zero-amount Convert as the way to register
130        // the holder's ElGamal key and initialize the confidential balance
131        // fields (it does explicit freeze/auth checks precisely for that case).
132        validate_mpt_amount("mpt_amount", self.mpt_amount.as_ref(), false)?;
133        validate_hex_length(
134            "holder_encrypted_amount",
135            self.holder_encrypted_amount.as_ref(),
136            CIPHERTEXT_LENGTH,
137        )?;
138        validate_hex_length(
139            "issuer_encrypted_amount",
140            self.issuer_encrypted_amount.as_ref(),
141            CIPHERTEXT_LENGTH,
142        )?;
143        if let Some(auditor) = self.auditor_encrypted_amount.as_deref() {
144            validate_hex_length("auditor_encrypted_amount", auditor, CIPHERTEXT_LENGTH)?;
145        }
146        validate_hex_length(
147            "blinding_factor",
148            self.blinding_factor.as_ref(),
149            BLINDING_FACTOR_LENGTH,
150        )?;
151        if let Some(key) = self.holder_encryption_key.as_deref() {
152            validate_hex_length("holder_encryption_key", key, ENCRYPTION_KEY_LENGTH)?;
153        }
154        if let Some(proof) = self.zk_proof.as_deref() {
155            validate_hex_length("zk_proof", proof, SCHNORR_PROOF_LENGTH)?;
156        }
157        Ok(())
158    }
159}
160
161impl<'a> Transaction<'a, NoFlags> for ConfidentialMPTConvert<'a> {
162    fn get_transaction_type(&self) -> &TransactionType {
163        self.common_fields.get_transaction_type()
164    }
165
166    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
167        self.common_fields.get_common_fields()
168    }
169
170    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
171        self.common_fields.get_mut_common_fields()
172    }
173}
174
175impl<'a> CommonTransactionBuilder<'a, NoFlags> for ConfidentialMPTConvert<'a> {
176    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
177        &mut self.common_fields
178    }
179
180    fn into_self(self) -> Self {
181        self
182    }
183}
184
185impl<'a> ConfidentialMPTConvert<'a> {
186    #[allow(clippy::too_many_arguments)]
187    pub fn new(
188        account: Cow<'a, str>,
189        account_txn_id: Option<Cow<'a, str>>,
190        fee: Option<XRPAmount<'a>>,
191        last_ledger_sequence: Option<u32>,
192        memos: Option<Vec<Memo>>,
193        sequence: Option<u32>,
194        signers: Option<Vec<Signer>>,
195        source_tag: Option<u32>,
196        ticket_sequence: Option<u32>,
197        mptoken_issuance_id: Cow<'a, str>,
198        mpt_amount: Cow<'a, str>,
199        holder_encrypted_amount: Cow<'a, str>,
200        issuer_encrypted_amount: Cow<'a, str>,
201        blinding_factor: Cow<'a, str>,
202        holder_encryption_key: Option<Cow<'a, str>>,
203        auditor_encrypted_amount: Option<Cow<'a, str>>,
204        zk_proof: Option<Cow<'a, str>>,
205    ) -> Self {
206        Self {
207            common_fields: CommonFields::new(
208                account,
209                TransactionType::ConfidentialMPTConvert,
210                account_txn_id,
211                fee,
212                Some(FlagCollection::default()),
213                last_ledger_sequence,
214                memos,
215                None,
216                sequence,
217                signers,
218                None,
219                source_tag,
220                ticket_sequence,
221                None,
222            ),
223            mptoken_issuance_id,
224            mpt_amount,
225            holder_encrypted_amount,
226            issuer_encrypted_amount,
227            blinding_factor,
228            holder_encryption_key,
229            auditor_encrypted_amount,
230            zk_proof,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use alloc::string::ToString;
238
239    use super::*;
240
241    #[test]
242    fn test_serialize_first_convert_with_registration() {
243        let tx = ConfidentialMPTConvert {
244            common_fields: CommonFields {
245                account: "rUserAccount11111111111111111111".into(),
246                transaction_type: TransactionType::ConfidentialMPTConvert,
247                ..Default::default()
248            },
249            mptoken_issuance_id: "610F33B8EBF7EC795F822A454FB852156AEFE50BE0CB8326338A81CD74801864"
250                .into(),
251            mpt_amount: "1000".into(),
252            holder_encrypted_amount: "AD3F".repeat(33).into(),
253            issuer_encrypted_amount: "BC2E".repeat(33).into(),
254            blinding_factor: "EE".repeat(32).into(),
255            holder_encryption_key: Some("03".to_string() + &"8d".repeat(32)).map(Into::into),
256            auditor_encrypted_amount: None,
257            zk_proof: Some("AB".repeat(64).into()),
258        };
259
260        let json = serde_json::to_string(&tx).unwrap();
261        assert!(json.contains("\"TransactionType\":\"ConfidentialMPTConvert\""));
262        assert!(json.contains("\"HolderEncryptionKey\""));
263        assert!(json.contains("\"ZKProof\""));
264
265        let round_tripped: ConfidentialMPTConvert = serde_json::from_str(&json).unwrap();
266        assert_eq!(round_tripped, tx);
267    }
268
269    #[test]
270    fn test_serialize_subsequent_convert_no_key() {
271        let tx = ConfidentialMPTConvert {
272            common_fields: CommonFields {
273                account: "rUserAccount11111111111111111111".into(),
274                transaction_type: TransactionType::ConfidentialMPTConvert,
275                ..Default::default()
276            },
277            mptoken_issuance_id: "610F33".repeat(4).into(),
278            mpt_amount: "500".into(),
279            holder_encrypted_amount: "AD3F".repeat(33).into(),
280            issuer_encrypted_amount: "BC2E".repeat(33).into(),
281            blinding_factor: "EE".repeat(32).into(),
282            holder_encryption_key: None,
283            auditor_encrypted_amount: None,
284            zk_proof: None,
285        };
286
287        let json = serde_json::to_string(&tx).unwrap();
288        // Optional absent fields should not appear via skip_serializing_none.
289        assert!(!json.contains("\"HolderEncryptionKey\""));
290        assert!(!json.contains("\"ZKProof\""));
291    }
292
293    #[test]
294    fn test_new_builder_and_accessors() {
295        let mut tx = ConfidentialMPTConvert::new(
296            "rUserAccount11111111111111111111".into(),
297            None,
298            None,
299            None,
300            None,
301            None,
302            None,
303            None,
304            None,
305            "610F33".repeat(8).into(),
306            "1000".into(),
307            "AD3F".repeat(33).into(),
308            "BC2E".repeat(33).into(),
309            "EE".repeat(32).into(),
310            None,
311            None,
312            None,
313        )
314        .with_fee(XRPAmount::from("20000"))
315        .with_sequence(7);
316
317        // with_fee/with_sequence route through the builder's
318        // get_mut_common_fields() + into_self().
319        assert_eq!(tx.get_common_fields().sequence, Some(7));
320        assert_eq!(tx.get_common_fields().fee, Some(XRPAmount::from("20000")));
321        assert_eq!(
322            tx.get_transaction_type(),
323            &TransactionType::ConfidentialMPTConvert
324        );
325        // No currency amounts to validate, so Model::get_errors succeeds.
326        assert!(tx.get_errors().is_ok());
327
328        // Transaction::get_mut_common_fields (distinct from the builder's
329        // same-named method) — disambiguate via UFCS.
330        let common =
331            <ConfidentialMPTConvert as Transaction<'_, NoFlags>>::get_mut_common_fields(&mut tx);
332        assert_eq!(common.sequence, Some(7));
333    }
334
335    #[test]
336    fn test_serialize_with_auditor_mirror() {
337        let tx = ConfidentialMPTConvert {
338            common_fields: CommonFields {
339                account: "rUserAccount11111111111111111111".into(),
340                transaction_type: TransactionType::ConfidentialMPTConvert,
341                ..Default::default()
342            },
343            mptoken_issuance_id: "610F33".repeat(4).into(),
344            mpt_amount: "750".into(),
345            holder_encrypted_amount: "AD3F".repeat(33).into(),
346            issuer_encrypted_amount: "BC2E".repeat(33).into(),
347            blinding_factor: "EE".repeat(32).into(),
348            holder_encryption_key: None,
349            // Issuance with a registered AuditorEncryptionKey requires the mirror.
350            auditor_encrypted_amount: Some("CD".repeat(66).into()),
351            zk_proof: None,
352        };
353
354        let json = serde_json::to_string(&tx).unwrap();
355        assert!(json.contains("\"AuditorEncryptedAmount\""));
356
357        let round_tripped: ConfidentialMPTConvert = serde_json::from_str(&json).unwrap();
358        assert_eq!(round_tripped, tx);
359    }
360
361    // ACCT's AccountID is B5F762..37E8.
362    const ACCT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh";
363    // Issuance whose issuer AccountID (bytes 4..24) is ACCT.
364    const ISS_OF_ACCT: &str = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8";
365
366    fn valid_convert() -> ConfidentialMPTConvert<'static> {
367        ConfidentialMPTConvert {
368            common_fields: CommonFields {
369                account: ACCT.into(),
370                transaction_type: TransactionType::ConfidentialMPTConvert,
371                ..Default::default()
372            },
373            // Arbitrary issuance whose issuer is not ACCT.
374            mptoken_issuance_id: "610F33".repeat(8).into(),
375            mpt_amount: "1000".into(),
376            holder_encrypted_amount: "AD3F".repeat(33).into(),
377            issuer_encrypted_amount: "BC2E".repeat(33).into(),
378            blinding_factor: "EE".repeat(32).into(),
379            holder_encryption_key: None,
380            auditor_encrypted_amount: None,
381            zk_proof: None,
382        }
383    }
384
385    #[test]
386    fn test_valid_convert_passes() {
387        assert!(valid_convert().get_errors().is_ok());
388    }
389
390    #[test]
391    fn test_zero_amount_convert_allowed() {
392        // A zero-amount Convert is the on-purpose key-registration / init path.
393        let mut tx = valid_convert();
394        tx.mpt_amount = "0".into();
395        assert!(tx.get_errors().is_ok());
396    }
397
398    #[test]
399    fn test_account_is_issuer_rejected() {
400        let mut tx = valid_convert();
401        tx.mptoken_issuance_id = ISS_OF_ACCT.into();
402        assert!(tx.get_errors().is_err());
403    }
404
405    #[test]
406    fn test_amount_above_mpt_max_rejected() {
407        // 2^63 (i64::MAX + 1) parses as u64 but exceeds the on-ledger MPT cap.
408        let mut tx = valid_convert();
409        tx.mpt_amount = "9223372036854775808".into();
410        assert!(tx.get_errors().is_err());
411    }
412
413    #[test]
414    fn test_key_without_proof_rejected() {
415        // HolderEncryptionKey and the Schnorr ZKProof are all-or-nothing.
416        let mut tx = valid_convert();
417        tx.holder_encryption_key = Some(("03".to_string() + &"8d".repeat(32)).into());
418        assert!(tx.get_errors().is_err());
419    }
420}