Skip to main content

xrpl/models/transactions/
confidential_mpt_convert_back.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, COMMITMENT_LENGTH, CONVERT_BACK_PROOF_LENGTH,
16};
17use super::mptoken_issuance_set::validate_mptoken_issuance_id;
18use super::{CommonFields, CommonTransactionBuilder};
19
20/// A `ConfidentialMPTConvertBack` transaction converts confidential MPT
21/// value back to public form (XLS-0096 ยง10). The withdrawal amount is
22/// revealed plaintext; the holder proves it doesn't exceed their balance
23/// without revealing the balance itself.
24///
25/// The 816-byte `ZKProof` field carries:
26///   - 128 B compact AND-composed sigma (balance ownership + key linkage)
27///   - 688 B single Bulletproof (remainder is non-negative)
28#[skip_serializing_none]
29#[derive(
30    Debug,
31    Default,
32    Serialize,
33    Deserialize,
34    PartialEq,
35    Eq,
36    Clone,
37    xrpl_rust_macros::ValidateCurrencies,
38)]
39#[serde(rename_all = "PascalCase")]
40pub struct ConfidentialMPTConvertBack<'a> {
41    #[serde(flatten)]
42    pub common_fields: CommonFields<'a, NoFlags>,
43
44    #[serde(rename = "MPTokenIssuanceID")]
45    pub mptoken_issuance_id: Cow<'a, str>,
46
47    /// Plaintext withdrawal amount (revealed publicly).
48    #[serde(rename = "MPTAmount")]
49    pub mpt_amount: Cow<'a, str>,
50
51    /// 66-byte ElGamal ciphertext to be subtracted from holder's `CB_S`.
52    pub holder_encrypted_amount: Cow<'a, str>,
53
54    /// 66-byte ElGamal ciphertext to be subtracted from issuer mirror.
55    pub issuer_encrypted_amount: Cow<'a, str>,
56
57    /// 32-byte ElGamal randomness `r`. Revealed for deterministic
58    /// verification of the ciphertexts above.
59    pub blinding_factor: Cow<'a, str>,
60
61    /// 33-byte Pedersen commitment to the holder's current balance.
62    pub balance_commitment: Cow<'a, str>,
63
64    /// 816-byte composite proof.
65    #[serde(rename = "ZKProof")]
66    pub zk_proof: Cow<'a, str>,
67
68    /// 66-byte ciphertext for the auditor mirror. Required iff the
69    /// issuance has an `AuditorEncryptionKey` registered.
70    pub auditor_encrypted_amount: Option<Cow<'a, str>>,
71}
72
73impl<'a> Model for ConfidentialMPTConvertBack<'a> {
74    fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
75        self._get_field_length_errors()?;
76        self._get_issuer_role_error()?;
77        self.validate_currencies()
78    }
79}
80
81impl<'a> ConfidentialMPTConvertBack<'a> {
82    /// The issuer holds value only through its mirror balance, so it cannot be
83    /// the `Account` converting confidential value back to public
84    /// (`temMALFORMED`, `ConfidentialMPTConvertBack.cpp` preflight).
85    fn _get_issuer_role_error(&self) -> crate::models::XRPLModelResult<()> {
86        if address_is_issuer(
87            self.mptoken_issuance_id.as_ref(),
88            self.common_fields.account.as_ref(),
89        ) {
90            return Err(XRPLModelException::ValueEqualsValue {
91                field1: "account".into(),
92                field2: "issuer".into(),
93            });
94        }
95        Ok(())
96    }
97
98    fn _get_field_length_errors(&self) -> crate::models::XRPLModelResult<()> {
99        validate_mptoken_issuance_id(self.mptoken_issuance_id.as_ref())?;
100        // A zero-amount ConvertBack is a no-op; rippled rejects it.
101        validate_mpt_amount("mpt_amount", self.mpt_amount.as_ref(), true)?;
102        validate_hex_length(
103            "holder_encrypted_amount",
104            self.holder_encrypted_amount.as_ref(),
105            CIPHERTEXT_LENGTH,
106        )?;
107        validate_hex_length(
108            "issuer_encrypted_amount",
109            self.issuer_encrypted_amount.as_ref(),
110            CIPHERTEXT_LENGTH,
111        )?;
112        if let Some(auditor) = self.auditor_encrypted_amount.as_deref() {
113            validate_hex_length("auditor_encrypted_amount", auditor, CIPHERTEXT_LENGTH)?;
114        }
115        validate_hex_length(
116            "blinding_factor",
117            self.blinding_factor.as_ref(),
118            BLINDING_FACTOR_LENGTH,
119        )?;
120        validate_hex_length(
121            "balance_commitment",
122            self.balance_commitment.as_ref(),
123            COMMITMENT_LENGTH,
124        )?;
125        validate_hex_length(
126            "zk_proof",
127            self.zk_proof.as_ref(),
128            CONVERT_BACK_PROOF_LENGTH,
129        )
130    }
131}
132
133impl<'a> Transaction<'a, NoFlags> for ConfidentialMPTConvertBack<'a> {
134    fn get_transaction_type(&self) -> &TransactionType {
135        self.common_fields.get_transaction_type()
136    }
137
138    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
139        self.common_fields.get_common_fields()
140    }
141
142    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
143        self.common_fields.get_mut_common_fields()
144    }
145}
146
147impl<'a> CommonTransactionBuilder<'a, NoFlags> for ConfidentialMPTConvertBack<'a> {
148    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
149        &mut self.common_fields
150    }
151
152    fn into_self(self) -> Self {
153        self
154    }
155}
156
157impl<'a> ConfidentialMPTConvertBack<'a> {
158    #[allow(clippy::too_many_arguments)]
159    pub fn new(
160        account: Cow<'a, str>,
161        account_txn_id: Option<Cow<'a, str>>,
162        fee: Option<XRPAmount<'a>>,
163        last_ledger_sequence: Option<u32>,
164        memos: Option<Vec<Memo>>,
165        sequence: Option<u32>,
166        signers: Option<Vec<Signer>>,
167        source_tag: Option<u32>,
168        ticket_sequence: Option<u32>,
169        mptoken_issuance_id: Cow<'a, str>,
170        mpt_amount: Cow<'a, str>,
171        holder_encrypted_amount: Cow<'a, str>,
172        issuer_encrypted_amount: Cow<'a, str>,
173        blinding_factor: Cow<'a, str>,
174        balance_commitment: Cow<'a, str>,
175        zk_proof: Cow<'a, str>,
176        auditor_encrypted_amount: Option<Cow<'a, str>>,
177    ) -> Self {
178        Self {
179            common_fields: CommonFields::new(
180                account,
181                TransactionType::ConfidentialMPTConvertBack,
182                account_txn_id,
183                fee,
184                Some(FlagCollection::default()),
185                last_ledger_sequence,
186                memos,
187                None,
188                sequence,
189                signers,
190                None,
191                source_tag,
192                ticket_sequence,
193                None,
194            ),
195            mptoken_issuance_id,
196            mpt_amount,
197            holder_encrypted_amount,
198            issuer_encrypted_amount,
199            blinding_factor,
200            balance_commitment,
201            zk_proof,
202            auditor_encrypted_amount,
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_serialize() {
213        let tx = ConfidentialMPTConvertBack {
214            common_fields: CommonFields {
215                account: "rUserAccount11111111111111111111".into(),
216                transaction_type: TransactionType::ConfidentialMPTConvertBack,
217                ..Default::default()
218            },
219            mptoken_issuance_id: "610F33".repeat(8).into(),
220            mpt_amount: "500".into(),
221            holder_encrypted_amount: "AD".repeat(66).into(),
222            issuer_encrypted_amount: "BC".repeat(66).into(),
223            blinding_factor: "12".repeat(32).into(),
224            balance_commitment: "03".repeat(33).into(),
225            zk_proof: "AB".repeat(816).into(),
226            auditor_encrypted_amount: None,
227        };
228
229        let json = serde_json::to_string(&tx).unwrap();
230        assert!(json.contains("\"TransactionType\":\"ConfidentialMPTConvertBack\""));
231        assert!(json.contains("\"BalanceCommitment\""));
232
233        let round_tripped: ConfidentialMPTConvertBack = serde_json::from_str(&json).unwrap();
234        assert_eq!(round_tripped, tx);
235    }
236
237    #[test]
238    fn test_new_builder_and_accessors() {
239        let mut tx = ConfidentialMPTConvertBack::new(
240            "rUserAccount11111111111111111111".into(),
241            None,
242            None,
243            None,
244            None,
245            None,
246            None,
247            None,
248            None,
249            "610F33".repeat(8).into(),
250            "500".into(),
251            "AD".repeat(66).into(),
252            "BC".repeat(66).into(),
253            "12".repeat(32).into(),
254            "03".repeat(33).into(),
255            "AB".repeat(816).into(),
256            None,
257        )
258        .with_fee(XRPAmount::from("15000"))
259        .with_sequence(9);
260
261        assert_eq!(tx.get_common_fields().sequence, Some(9));
262        assert_eq!(tx.get_common_fields().fee, Some(XRPAmount::from("15000")));
263        assert_eq!(
264            tx.get_transaction_type(),
265            &TransactionType::ConfidentialMPTConvertBack
266        );
267        assert!(tx.get_errors().is_ok());
268
269        let common =
270            <ConfidentialMPTConvertBack as Transaction<'_, NoFlags>>::get_mut_common_fields(
271                &mut tx,
272            );
273        assert_eq!(common.sequence, Some(9));
274    }
275
276    // ACCT's AccountID is B5F762..37E8.
277    const ACCT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh";
278    // Issuance whose issuer AccountID (bytes 4..24) is ACCT.
279    const ISS_OF_ACCT: &str = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8";
280
281    fn valid_convert_back() -> ConfidentialMPTConvertBack<'static> {
282        ConfidentialMPTConvertBack {
283            common_fields: CommonFields {
284                account: ACCT.into(),
285                transaction_type: TransactionType::ConfidentialMPTConvertBack,
286                ..Default::default()
287            },
288            // Arbitrary issuance whose issuer is not ACCT.
289            mptoken_issuance_id: "610F33".repeat(8).into(),
290            mpt_amount: "500".into(),
291            holder_encrypted_amount: "AD".repeat(66).into(),
292            issuer_encrypted_amount: "BC".repeat(66).into(),
293            blinding_factor: "12".repeat(32).into(),
294            balance_commitment: "03".repeat(33).into(),
295            zk_proof: "AB".repeat(816).into(),
296            auditor_encrypted_amount: None,
297        }
298    }
299
300    #[test]
301    fn test_valid_convert_back_passes() {
302        assert!(valid_convert_back().get_errors().is_ok());
303    }
304
305    #[test]
306    fn test_zero_amount_convert_back_rejected() {
307        // Unlike Convert, a zero-amount ConvertBack is a no-op and rejected.
308        let mut tx = valid_convert_back();
309        tx.mpt_amount = "0".into();
310        assert!(tx.get_errors().is_err());
311    }
312
313    #[test]
314    fn test_account_is_issuer_rejected() {
315        let mut tx = valid_convert_back();
316        tx.mptoken_issuance_id = ISS_OF_ACCT.into();
317        assert!(tx.get_errors().is_err());
318    }
319
320    #[test]
321    fn test_amount_above_mpt_max_rejected() {
322        let mut tx = valid_convert_back();
323        tx.mpt_amount = "9223372036854775808".into();
324        assert!(tx.get_errors().is_err());
325    }
326}