Skip to main content

xrpl/models/transactions/
confidential_mpt_clawback.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, CLAWBACK_PROOF_LENGTH,
15};
16use super::mptoken_issuance_set::{validate_holder_address, validate_mptoken_issuance_id};
17use super::{CommonFields, CommonTransactionBuilder};
18
19/// A `ConfidentialMPTClawback` transaction is an issuer-only operation
20/// that reclaims a holder's confidential balance, decrypting it via the
21/// issuer's mirror key and burning the result (XLS-0096 §11).
22///
23/// The 64-byte `ZKProof` is a compact sigma proof that the holder's
24/// `IssuerEncryptedBalance` ciphertext encrypts the plaintext `MPTAmount`
25/// the issuer is reclaiming. The transaction simultaneously decreases both
26/// `OutstandingAmount` and `ConfidentialOutstandingAmount` — effectively
27/// burning the clawed-back tokens.
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 ConfidentialMPTClawback<'a> {
41    /// `Account` here is the issuer initiating the clawback.
42    #[serde(flatten)]
43    pub common_fields: CommonFields<'a, NoFlags>,
44
45    /// The holder being clawed back.
46    pub holder: Cow<'a, str>,
47
48    #[serde(rename = "MPTokenIssuanceID")]
49    pub mptoken_issuance_id: Cow<'a, str>,
50
51    /// The plaintext total amount being reclaimed (decrypted by the issuer
52    /// from the holder's `IssuerEncryptedBalance` mirror).
53    #[serde(rename = "MPTAmount")]
54    pub mpt_amount: Cow<'a, str>,
55
56    /// 64-byte compact Clawback sigma proof.
57    #[serde(rename = "ZKProof")]
58    pub zk_proof: Cow<'a, str>,
59}
60
61impl<'a> Model for ConfidentialMPTClawback<'a> {
62    fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
63        self._get_holder_error()?;
64        self._get_field_length_errors()?;
65        self._get_issuer_role_error()?;
66        self.validate_currencies()
67    }
68}
69
70impl<'a> ConfidentialMPTClawback<'a> {
71    /// An issuer cannot claw back from itself (`temMALFORMED`).
72    fn _get_holder_error(&self) -> crate::models::XRPLModelResult<()> {
73        validate_holder_address(self.holder.as_ref())?;
74        if self.holder == self.common_fields.account {
75            return Err(XRPLModelException::ValueEqualsValue {
76                field1: "holder".into(),
77                field2: "account".into(),
78            });
79        }
80        Ok(())
81    }
82
83    /// Clawback is issuer-only: `Account` MUST be the issuance's issuer,
84    /// otherwise rippled rejects with `temMALFORMED`
85    /// (`ConfidentialMPTClawback.cpp` preflight `account != issuer`). Requires a
86    /// well-formed `MPTokenIssuanceID`, so it is checked after the length pass.
87    fn _get_issuer_role_error(&self) -> crate::models::XRPLModelResult<()> {
88        if !address_is_issuer(
89            self.mptoken_issuance_id.as_ref(),
90            self.common_fields.account.as_ref(),
91        ) {
92            return Err(XRPLModelException::InvalidValue {
93                field: "account".into(),
94                expected: "the issuance's issuer (ConfidentialMPTClawback is issuer-only)".into(),
95                found: self.common_fields.account.as_ref().into(),
96            });
97        }
98        Ok(())
99    }
100
101    fn _get_field_length_errors(&self) -> crate::models::XRPLModelResult<()> {
102        validate_mptoken_issuance_id(self.mptoken_issuance_id.as_ref())?;
103        validate_mpt_amount("mpt_amount", self.mpt_amount.as_ref(), true)?;
104        validate_hex_length("zk_proof", self.zk_proof.as_ref(), CLAWBACK_PROOF_LENGTH)
105    }
106}
107
108impl<'a> Transaction<'a, NoFlags> for ConfidentialMPTClawback<'a> {
109    fn get_transaction_type(&self) -> &TransactionType {
110        self.common_fields.get_transaction_type()
111    }
112
113    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
114        self.common_fields.get_common_fields()
115    }
116
117    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
118        self.common_fields.get_mut_common_fields()
119    }
120}
121
122impl<'a> CommonTransactionBuilder<'a, NoFlags> for ConfidentialMPTClawback<'a> {
123    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
124        &mut self.common_fields
125    }
126
127    fn into_self(self) -> Self {
128        self
129    }
130}
131
132impl<'a> ConfidentialMPTClawback<'a> {
133    #[allow(clippy::too_many_arguments)]
134    pub fn new(
135        account: Cow<'a, str>,
136        account_txn_id: Option<Cow<'a, str>>,
137        fee: Option<XRPAmount<'a>>,
138        last_ledger_sequence: Option<u32>,
139        memos: Option<Vec<Memo>>,
140        sequence: Option<u32>,
141        signers: Option<Vec<Signer>>,
142        source_tag: Option<u32>,
143        ticket_sequence: Option<u32>,
144        holder: Cow<'a, str>,
145        mptoken_issuance_id: Cow<'a, str>,
146        mpt_amount: Cow<'a, str>,
147        zk_proof: Cow<'a, str>,
148    ) -> Self {
149        Self {
150            common_fields: CommonFields::new(
151                account,
152                TransactionType::ConfidentialMPTClawback,
153                account_txn_id,
154                fee,
155                Some(FlagCollection::default()),
156                last_ledger_sequence,
157                memos,
158                None,
159                sequence,
160                signers,
161                None,
162                source_tag,
163                ticket_sequence,
164                None,
165            ),
166            holder,
167            mptoken_issuance_id,
168            mpt_amount,
169            zk_proof,
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_serialize() {
180        let tx = ConfidentialMPTClawback {
181            common_fields: CommonFields {
182                account: "rIssuerAccount11111111111111".into(),
183                transaction_type: TransactionType::ConfidentialMPTClawback,
184                ..Default::default()
185            },
186            holder: "rHolderAccount11111111111111".into(),
187            mptoken_issuance_id: "610F33".repeat(8).into(),
188            mpt_amount: "1000".into(),
189            zk_proof: "a1".repeat(64).into(),
190        };
191
192        let json = serde_json::to_string(&tx).unwrap();
193        assert!(json.contains("\"TransactionType\":\"ConfidentialMPTClawback\""));
194        assert!(json.contains("\"Holder\":\"rHolderAccount"));
195
196        let round_tripped: ConfidentialMPTClawback = serde_json::from_str(&json).unwrap();
197        assert_eq!(round_tripped, tx);
198    }
199
200    #[test]
201    fn test_new_builder_and_accessors() {
202        let mut tx = ConfidentialMPTClawback::new(
203            "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(),
204            None,
205            None,
206            None,
207            None,
208            None,
209            None,
210            None,
211            None,
212            "rLSn6Z3T8uCxbcd1oxwfGQN1Fdn5CyGujK".into(),
213            // Clawback is issuer-only: the issuance ID must embed the submitting
214            // account (rHb9...) as its issuer — sequence(8 hex) || issuerAccountID.
215            "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8".into(),
216            "1000".into(),
217            "a1".repeat(64).into(),
218        )
219        .with_fee(XRPAmount::from("15000"))
220        .with_sequence(9);
221
222        assert_eq!(tx.get_common_fields().sequence, Some(9));
223        assert_eq!(tx.get_common_fields().fee, Some(XRPAmount::from("15000")));
224        assert_eq!(
225            tx.get_transaction_type(),
226            &TransactionType::ConfidentialMPTClawback
227        );
228        assert!(tx.get_errors().is_ok());
229
230        let common =
231            <ConfidentialMPTClawback as Transaction<'_, NoFlags>>::get_mut_common_fields(&mut tx);
232        assert_eq!(common.sequence, Some(9));
233    }
234
235    // ISSUER's AccountID is B5F762..37E8, HOLDER's is D528B6..705F.
236    const ISSUER: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh";
237    const HOLDER: &str = "rLSn6Z3T8uCxbcd1oxwfGQN1Fdn5CyGujK";
238    // Issuance whose issuer AccountID (bytes 4..24) is ISSUER.
239    const ISS_OF_ISSUER: &str = "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8";
240
241    fn valid_clawback() -> ConfidentialMPTClawback<'static> {
242        ConfidentialMPTClawback {
243            common_fields: CommonFields {
244                account: ISSUER.into(),
245                transaction_type: TransactionType::ConfidentialMPTClawback,
246                ..Default::default()
247            },
248            holder: HOLDER.into(),
249            mptoken_issuance_id: ISS_OF_ISSUER.into(),
250            mpt_amount: "1000".into(),
251            zk_proof: "a1".repeat(64).into(),
252        }
253    }
254
255    #[test]
256    fn test_valid_clawback_passes() {
257        assert!(valid_clawback().get_errors().is_ok());
258    }
259
260    #[test]
261    fn test_non_issuer_account_rejected() {
262        // Clawback is issuer-only: an issuance whose issuer is not Account fails.
263        let mut tx = valid_clawback();
264        tx.mptoken_issuance_id = "610F33".repeat(8).into();
265        assert!(tx.get_errors().is_err());
266    }
267
268    #[test]
269    fn test_self_clawback_rejected() {
270        // holder == account (which is also the issuer here) is still malformed.
271        let mut tx = valid_clawback();
272        tx.holder = ISSUER.into();
273        assert!(tx.get_errors().is_err());
274    }
275
276    #[test]
277    fn test_zero_amount_rejected() {
278        let mut tx = valid_clawback();
279        tx.mpt_amount = "0".into();
280        assert!(tx.get_errors().is_err());
281    }
282
283    #[test]
284    fn test_amount_above_mpt_max_rejected() {
285        let mut tx = valid_clawback();
286        tx.mpt_amount = "9223372036854775808".into();
287        assert!(tx.get_errors().is_err());
288    }
289
290    #[test]
291    fn test_malformed_holder_rejected() {
292        let mut tx = valid_clawback();
293        tx.holder = "not_a_classic_address".into();
294        assert!(tx.get_errors().is_err());
295    }
296}