Skip to main content

xrpl/models/transactions/
confidential_mpt_send.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 crate::core::addresscodec::decode_classic_address;
14
15use super::confidential_mpt_constants::{
16    address_is_issuer, validate_hex_length, CIPHERTEXT_LENGTH, COMMITMENT_LENGTH, SEND_PROOF_LENGTH,
17};
18use super::mptoken_issuance_set::validate_mptoken_issuance_id;
19use super::{validate_credential_ids, CommonFields, CommonTransactionBuilder};
20
21/// A `ConfidentialMPTSend` transaction transfers a confidential MPT amount
22/// from sender to destination, hiding the amount under EC-ElGamal
23/// encryption (XLS-0096 §8). The amount is decrypted only by the recipient
24/// (and the issuer / optional auditor via their mirror keys).
25///
26/// The 946-byte `ZKProof` field carries:
27///   - 192 B compact AND-composed sigma proof (ciphertext consistency,
28///     Pedersen amount linkage, balance ownership)
29///   - 754 B aggregated Bulletproof (range proof on amount AND remainder)
30///
31/// `CredentialIDs` (XLS-70) are honored when the destination requires
32/// pre-authorization.
33#[skip_serializing_none]
34#[derive(
35    Debug,
36    Default,
37    Serialize,
38    Deserialize,
39    PartialEq,
40    Eq,
41    Clone,
42    xrpl_rust_macros::ValidateCurrencies,
43)]
44#[serde(rename_all = "PascalCase")]
45pub struct ConfidentialMPTSend<'a> {
46    #[serde(flatten)]
47    pub common_fields: CommonFields<'a, NoFlags>,
48
49    /// Destination XRPL account.
50    pub destination: Cow<'a, str>,
51
52    /// Arbitrary tag that identifies the reason for the transfer, or a hosted
53    /// recipient at the destination account.
54    pub destination_tag: Option<u32>,
55
56    #[serde(rename = "MPTokenIssuanceID")]
57    pub mptoken_issuance_id: Cow<'a, str>,
58
59    /// 66-byte ElGamal ciphertext debited from the sender's `CB_S`.
60    pub sender_encrypted_amount: Cow<'a, str>,
61
62    /// 66-byte ElGamal ciphertext credited to the receiver's `CB_IN`.
63    pub destination_encrypted_amount: Cow<'a, str>,
64
65    /// 66-byte ElGamal ciphertext used to update both the sender's and
66    /// receiver's `IssuerEncryptedBalance` mirrors.
67    pub issuer_encrypted_amount: Cow<'a, str>,
68
69    /// 33-byte Pedersen commitment to the transfer amount.
70    pub amount_commitment: Cow<'a, str>,
71
72    /// 33-byte Pedersen commitment to the sender's confidential balance.
73    pub balance_commitment: Cow<'a, str>,
74
75    /// 946-byte composite ZK proof (192 B compact sigma + 754 B aggregated
76    /// Bulletproof).
77    #[serde(rename = "ZKProof")]
78    pub zk_proof: Cow<'a, str>,
79
80    /// 66-byte ciphertext for the auditor mirror. Required iff the
81    /// issuance has an `AuditorEncryptionKey` registered.
82    pub auditor_encrypted_amount: Option<Cow<'a, str>>,
83
84    /// XLS-70 credentials presented to satisfy the destination's
85    /// `DepositPreauth` / `AuthorizeCredentials` requirement, if any.
86    #[serde(rename = "CredentialIDs")]
87    pub credential_ids: Option<Vec<Cow<'a, str>>>,
88}
89
90impl<'a> Model for ConfidentialMPTSend<'a> {
91    fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
92        self._get_destination_error()?;
93        self._get_field_length_errors()?;
94        self._get_issuer_role_error()?;
95        validate_credential_ids(&self.credential_ids)?;
96        self.validate_currencies()
97    }
98}
99
100impl<'a> ConfidentialMPTSend<'a> {
101    /// rippled rejects a malformed destination or a self-send (`temMALFORMED`).
102    fn _get_destination_error(&self) -> crate::models::XRPLModelResult<()> {
103        if decode_classic_address(self.destination.as_ref()).is_err() {
104            return Err(XRPLModelException::InvalidValueFormat {
105                field: "destination".into(),
106                format: "classic XRPL address".into(),
107                found: self.destination.as_ref().into(),
108            });
109        }
110        if self.destination == self.common_fields.account {
111            return Err(XRPLModelException::ValueEqualsValue {
112                field1: "destination".into(),
113                field2: "account".into(),
114            });
115        }
116        Ok(())
117    }
118
119    /// rippled bans the issuer as either party of a confidential send: a
120    /// `ConfidentialMPTSend` only moves value holder↔holder, so `Account` and
121    /// `Destination` must both differ from the issuance's issuer (`temMALFORMED`,
122    /// `ConfidentialMPTSend.cpp` preflight).
123    fn _get_issuer_role_error(&self) -> crate::models::XRPLModelResult<()> {
124        let issuance_id = self.mptoken_issuance_id.as_ref();
125        if address_is_issuer(issuance_id, self.common_fields.account.as_ref()) {
126            return Err(XRPLModelException::ValueEqualsValue {
127                field1: "account".into(),
128                field2: "issuer".into(),
129            });
130        }
131        if address_is_issuer(issuance_id, self.destination.as_ref()) {
132            return Err(XRPLModelException::ValueEqualsValue {
133                field1: "destination".into(),
134                field2: "issuer".into(),
135            });
136        }
137        Ok(())
138    }
139
140    /// Ciphertext, commitment and proof lengths (`temBAD_CIPHERTEXT` /
141    /// `temMALFORMED` in rippled's preflight).
142    fn _get_field_length_errors(&self) -> crate::models::XRPLModelResult<()> {
143        validate_mptoken_issuance_id(self.mptoken_issuance_id.as_ref())?;
144        validate_hex_length(
145            "sender_encrypted_amount",
146            self.sender_encrypted_amount.as_ref(),
147            CIPHERTEXT_LENGTH,
148        )?;
149        validate_hex_length(
150            "destination_encrypted_amount",
151            self.destination_encrypted_amount.as_ref(),
152            CIPHERTEXT_LENGTH,
153        )?;
154        validate_hex_length(
155            "issuer_encrypted_amount",
156            self.issuer_encrypted_amount.as_ref(),
157            CIPHERTEXT_LENGTH,
158        )?;
159        if let Some(auditor) = self.auditor_encrypted_amount.as_deref() {
160            validate_hex_length("auditor_encrypted_amount", auditor, CIPHERTEXT_LENGTH)?;
161        }
162        validate_hex_length(
163            "amount_commitment",
164            self.amount_commitment.as_ref(),
165            COMMITMENT_LENGTH,
166        )?;
167        validate_hex_length(
168            "balance_commitment",
169            self.balance_commitment.as_ref(),
170            COMMITMENT_LENGTH,
171        )?;
172        validate_hex_length("zk_proof", self.zk_proof.as_ref(), SEND_PROOF_LENGTH)
173    }
174}
175
176impl<'a> Transaction<'a, NoFlags> for ConfidentialMPTSend<'a> {
177    fn get_transaction_type(&self) -> &TransactionType {
178        self.common_fields.get_transaction_type()
179    }
180
181    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
182        self.common_fields.get_common_fields()
183    }
184
185    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
186        self.common_fields.get_mut_common_fields()
187    }
188}
189
190impl<'a> CommonTransactionBuilder<'a, NoFlags> for ConfidentialMPTSend<'a> {
191    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
192        &mut self.common_fields
193    }
194
195    fn into_self(self) -> Self {
196        self
197    }
198}
199
200impl<'a> ConfidentialMPTSend<'a> {
201    #[allow(clippy::too_many_arguments)]
202    pub fn new(
203        account: Cow<'a, str>,
204        account_txn_id: Option<Cow<'a, str>>,
205        fee: Option<XRPAmount<'a>>,
206        last_ledger_sequence: Option<u32>,
207        memos: Option<Vec<Memo>>,
208        sequence: Option<u32>,
209        signers: Option<Vec<Signer>>,
210        source_tag: Option<u32>,
211        ticket_sequence: Option<u32>,
212        destination: Cow<'a, str>,
213        destination_tag: Option<u32>,
214        mptoken_issuance_id: Cow<'a, str>,
215        sender_encrypted_amount: Cow<'a, str>,
216        destination_encrypted_amount: Cow<'a, str>,
217        issuer_encrypted_amount: Cow<'a, str>,
218        amount_commitment: Cow<'a, str>,
219        balance_commitment: Cow<'a, str>,
220        zk_proof: Cow<'a, str>,
221        auditor_encrypted_amount: Option<Cow<'a, str>>,
222        credential_ids: Option<Vec<Cow<'a, str>>>,
223    ) -> Self {
224        Self {
225            common_fields: CommonFields::new(
226                account,
227                TransactionType::ConfidentialMPTSend,
228                account_txn_id,
229                fee,
230                Some(FlagCollection::default()),
231                last_ledger_sequence,
232                memos,
233                None,
234                sequence,
235                signers,
236                None,
237                source_tag,
238                ticket_sequence,
239                None,
240            ),
241            destination,
242            destination_tag,
243            mptoken_issuance_id,
244            sender_encrypted_amount,
245            destination_encrypted_amount,
246            issuer_encrypted_amount,
247            amount_commitment,
248            balance_commitment,
249            zk_proof,
250            auditor_encrypted_amount,
251            credential_ids,
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_serialize() {
262        let tx = ConfidentialMPTSend {
263            common_fields: CommonFields {
264                account: "rSenderAccount11111111111111111".into(),
265                transaction_type: TransactionType::ConfidentialMPTSend,
266                ..Default::default()
267            },
268            destination: "rRecipientAccount111111111111".into(),
269            destination_tag: None,
270            mptoken_issuance_id: "610F33".repeat(8).into(),
271            sender_encrypted_amount: "AD".repeat(66).into(),
272            destination_encrypted_amount: "DF".repeat(66).into(),
273            issuer_encrypted_amount: "BC".repeat(66).into(),
274            amount_commitment: "04".repeat(33).into(),
275            balance_commitment: "03".repeat(33).into(),
276            zk_proof: "84".repeat(946).into(),
277            auditor_encrypted_amount: None,
278            credential_ids: None,
279        };
280
281        let json = serde_json::to_string(&tx).unwrap();
282        assert!(json.contains("\"TransactionType\":\"ConfidentialMPTSend\""));
283        assert!(json.contains("\"Destination\":\"rRecipientAccount"));
284        assert!(json.contains("\"AmountCommitment\""));
285        assert!(json.contains("\"BalanceCommitment\""));
286        assert!(json.contains("\"ZKProof\""));
287
288        let round_tripped: ConfidentialMPTSend = serde_json::from_str(&json).unwrap();
289        assert_eq!(round_tripped, tx);
290    }
291
292    #[test]
293    fn test_new_builder_and_accessors() {
294        let mut tx = ConfidentialMPTSend::new(
295            "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(),
296            None,
297            None,
298            None,
299            None,
300            None,
301            None,
302            None,
303            None,
304            "rLSn6Z3T8uCxbcd1oxwfGQN1Fdn5CyGujK".into(),
305            None,
306            "610F33".repeat(8).into(),
307            "AD".repeat(66).into(),
308            "DF".repeat(66).into(),
309            "BC".repeat(66).into(),
310            "04".repeat(33).into(),
311            "03".repeat(33).into(),
312            "84".repeat(946).into(),
313            None,
314            None,
315        )
316        .with_fee(XRPAmount::from("15000"))
317        .with_sequence(9);
318
319        assert_eq!(tx.get_common_fields().sequence, Some(9));
320        assert_eq!(tx.get_common_fields().fee, Some(XRPAmount::from("15000")));
321        assert_eq!(
322            tx.get_transaction_type(),
323            &TransactionType::ConfidentialMPTSend
324        );
325        assert!(tx.get_errors().is_ok());
326
327        let common =
328            <ConfidentialMPTSend as Transaction<'_, NoFlags>>::get_mut_common_fields(&mut tx);
329        assert_eq!(common.sequence, Some(9));
330    }
331
332    // Two valid, distinct classic addresses with known AccountIDs:
333    // ACCT -> B5F762..37E8, DEST -> D528B6..705F.
334    const ACCT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh";
335    const DEST: &str = "rLSn6Z3T8uCxbcd1oxwfGQN1Fdn5CyGujK";
336    // Issuance IDs whose issuer AccountID (bytes 4..24) is ACCT / DEST.
337    const ISS_OF_ACCT: &str = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8";
338    const ISS_OF_DEST: &str = "00000001D528B62DC7AF16417C9F44AAD8C04D920A3A705F";
339
340    fn valid_send() -> ConfidentialMPTSend<'static> {
341        ConfidentialMPTSend {
342            common_fields: CommonFields {
343                account: ACCT.into(),
344                transaction_type: TransactionType::ConfidentialMPTSend,
345                ..Default::default()
346            },
347            destination: DEST.into(),
348            destination_tag: None,
349            // Arbitrary issuance whose issuer is neither ACCT nor DEST.
350            mptoken_issuance_id: "610F33".repeat(8).into(),
351            sender_encrypted_amount: "AD".repeat(66).into(),
352            destination_encrypted_amount: "DF".repeat(66).into(),
353            issuer_encrypted_amount: "BC".repeat(66).into(),
354            amount_commitment: "04".repeat(33).into(),
355            balance_commitment: "03".repeat(33).into(),
356            zk_proof: "84".repeat(946).into(),
357            auditor_encrypted_amount: None,
358            credential_ids: None,
359        }
360    }
361
362    #[test]
363    fn test_valid_send_passes() {
364        assert!(valid_send().get_errors().is_ok());
365    }
366
367    #[test]
368    fn test_self_send_rejected() {
369        let mut tx = valid_send();
370        tx.destination = ACCT.into();
371        assert!(tx.get_errors().is_err());
372    }
373
374    #[test]
375    fn test_malformed_destination_rejected() {
376        let mut tx = valid_send();
377        tx.destination = "not_a_classic_address".into();
378        assert!(tx.get_errors().is_err());
379    }
380
381    #[test]
382    fn test_account_is_issuer_rejected() {
383        let mut tx = valid_send();
384        tx.mptoken_issuance_id = ISS_OF_ACCT.into();
385        assert!(tx.get_errors().is_err());
386    }
387
388    #[test]
389    fn test_destination_is_issuer_rejected() {
390        let mut tx = valid_send();
391        tx.mptoken_issuance_id = ISS_OF_DEST.into();
392        assert!(tx.get_errors().is_err());
393    }
394
395    #[test]
396    fn test_wrong_length_ciphertext_rejected() {
397        let mut tx = valid_send();
398        tx.sender_encrypted_amount = "AD".repeat(10).into();
399        assert!(tx.get_errors().is_err());
400    }
401}