Skip to main content

xrpl/
confidential.rs

1//! High-level assembly of Confidential MPT (XLS-0096) transactions.
2//!
3//! The [`mpt_crypto`](crate::mpt_crypto) crate exposes the cryptographic
4//! primitives (keypairs, ElGamal encrypt/decrypt, Pedersen commitments, context
5//! hashes, and the four proof generators). This module wraps them into the
6//! full flow a client needs — encrypt → commit → context-hash → prove →
7//! populate the model — returning a ready-to-sign `ConfidentialMPT*`
8//! transaction. It mirrors xrpl-py's `xrpl.ext.confidential.transaction_builders`
9//! _assemble_ layer.
10//!
11//! These functions are **pure**: the caller supplies mutable ledger-derived
12//! state (the account `Sequence`, and for Send/ConvertBack the on-ledger
13//! `ConfidentialBalanceSpending` ciphertext + `ConfidentialBalanceVersion` +
14//! the decrypted current balance) rather than a client. Read that state with
15//! the existing request models, then call the matching assembler.
16//!
17//! The returned transaction pins `Sequence`: every proof's context hash is
18//! bound to that exact value, so autofill must not substitute a different one.
19//!
20//! Requires the `confidential-mpt` feature.
21
22use alloc::borrow::Cow;
23use alloc::string::{String, ToString};
24
25use thiserror_no_std::Error;
26
27use crate::core::addresscodec::decode_classic_address;
28use crate::models::transactions::confidential_mpt_clawback::ConfidentialMPTClawback;
29use crate::models::transactions::confidential_mpt_convert::ConfidentialMPTConvert;
30use crate::models::transactions::confidential_mpt_convert_back::ConfidentialMPTConvertBack;
31use crate::models::transactions::confidential_mpt_merge_inbox::ConfidentialMPTMergeInbox;
32use crate::models::transactions::confidential_mpt_send::ConfidentialMPTSend;
33use crate::models::transactions::{CommonFields, TransactionType};
34use crate::models::NoFlags;
35use crate::mpt_crypto::{
36    commit, context, encrypt, prove, AccountId, Ciphertext, IssuanceId, Privkey, Pubkey,
37};
38
39/// Errors returned while assembling a Confidential MPT transaction.
40#[derive(Debug, Error)]
41pub enum ConfidentialAssemblyError {
42    /// A cryptographic operation (encrypt, commit, prove, …) failed.
43    #[error("mpt-crypto error: {0}")]
44    Crypto(#[from] crate::mpt_crypto::Error),
45    /// The account string is not a decodable classic XRPL address.
46    #[error("invalid classic address: {0}")]
47    InvalidAddress(String),
48    /// The `MPTokenIssuanceID` is not 24 bytes of hex (48 chars).
49    #[error("invalid MPTokenIssuanceID (expected 48-char hex): {0}")]
50    InvalidIssuanceId(String),
51    /// An on-ledger ciphertext blob is not 66 bytes of hex (132 chars).
52    #[error("invalid ciphertext (expected 132-char hex): {0}")]
53    InvalidCiphertext(String),
54    /// A ledger query failed, returned an error, or was missing an expected
55    /// field (only produced by the `prepare_confidential_*` client helpers).
56    #[error("ledger query error: {0}")]
57    Ledger(String),
58    /// A Send/ConvertBack spends more than the account's decrypted spending
59    /// balance. The range proof over the remainder could not be built otherwise
60    /// (the balance would go negative), so reject it up front with a clear error.
61    #[error("amount {amount} exceeds confidential spending balance {balance}")]
62    InsufficientBalance {
63        /// The amount being spent.
64        amount: u64,
65        /// The decrypted current spending balance.
66        balance: u64,
67    },
68}
69
70type Result<T> = core::result::Result<T, ConfidentialAssemblyError>;
71
72fn account_id(address: &str) -> Result<AccountId> {
73    let bytes: [u8; 20] = decode_classic_address(address)
74        .ok()
75        .and_then(|v| v.try_into().ok())
76        .ok_or_else(|| ConfidentialAssemblyError::InvalidAddress(address.to_string()))?;
77    Ok(AccountId::new(bytes))
78}
79
80fn issuance_id(hex: &str) -> Result<IssuanceId> {
81    let bytes: [u8; 24] = hex::decode(hex)
82        .ok()
83        .and_then(|v| v.try_into().ok())
84        .ok_or_else(|| ConfidentialAssemblyError::InvalidIssuanceId(hex.to_string()))?;
85    Ok(IssuanceId::new(bytes))
86}
87
88fn ciphertext_from_hex(hex: &str) -> Result<Ciphertext> {
89    let bytes: [u8; 66] = hex::decode(hex)
90        .ok()
91        .and_then(|v| v.try_into().ok())
92        .ok_or_else(|| ConfidentialAssemblyError::InvalidCiphertext(hex.to_string()))?;
93    Ok(Ciphertext::new(bytes))
94}
95
96fn upper_hex(bytes: &[u8]) -> String {
97    hex::encode_upper(bytes)
98}
99
100fn common(
101    account: &str,
102    tx_type: TransactionType,
103    sequence: u32,
104) -> CommonFields<'static, NoFlags> {
105    CommonFields {
106        account: Cow::Owned(account.to_string()),
107        transaction_type: tx_type,
108        // Pin the sequence: the proof's context hash is bound to it.
109        sequence: Some(sequence),
110        ..Default::default()
111    }
112}
113
114/// Decrypt an on-ledger ElGamal balance blob (66-byte `c1||c2` hex, e.g.
115/// `ConfidentialBalanceSpending`) with the holder's private key.
116pub fn decrypt_balance(ciphertext_hex: &str, privkey: &Privkey) -> Result<u64> {
117    Ok(encrypt::decrypt(
118        &ciphertext_from_hex(ciphertext_hex)?,
119        privkey,
120    )?)
121}
122
123/// Decrypt an on-ledger balance blob, bounding the discrete-log search to
124/// `[range_low, range_high]` (cheaper than the unbounded [`decrypt_balance`]
125/// when a tight upper bound — e.g. the issuance's outstanding amount — is known).
126pub fn decrypt_balance_in_range(
127    ciphertext_hex: &str,
128    privkey: &Privkey,
129    range_low: u64,
130    range_high: u64,
131) -> Result<u64> {
132    Ok(encrypt::decrypt_in_range(
133        &ciphertext_from_hex(ciphertext_hex)?,
134        privkey,
135        range_low,
136        range_high,
137    )?)
138}
139
140/// Inputs for [`assemble_convert`].
141pub struct ConvertParams<'a> {
142    /// The converting holder's classic address.
143    pub account: &'a str,
144    /// 48-char hex `MPTokenIssuanceID`.
145    pub issuance_id_hex: &'a str,
146    /// The account `Sequence` this transaction will be submitted with.
147    pub sequence: u32,
148    /// Public amount to convert into confidential form.
149    pub amount: u64,
150    /// The issuer's ElGamal public key (for the issuer mirror ciphertext).
151    pub issuer_pubkey: &'a Pubkey,
152    /// The holder's ElGamal keypair.
153    pub holder_privkey: &'a Privkey,
154    pub holder_pubkey: &'a Pubkey,
155    /// The optional auditor's ElGamal public key. Required iff the issuance has
156    /// an `AuditorEncryptionKey` registered.
157    pub auditor_pubkey: Option<&'a Pubkey>,
158    /// `true` for the first convert (registers the holder key + Schnorr PoK);
159    /// `false` for subsequent converts (rippled returns `tecDUPLICATE` if the
160    /// key is re-registered).
161    pub register_key: bool,
162}
163
164/// Assemble a `ConfidentialMPTConvert` (public → confidential).
165pub fn assemble_convert(p: ConvertParams<'_>) -> Result<ConfidentialMPTConvert<'static>> {
166    let r = encrypt::random_blinding_factor()?;
167    let holder_ct = encrypt::encrypt(p.amount, p.holder_pubkey, &r)?;
168    let issuer_ct = encrypt::encrypt(p.amount, p.issuer_pubkey, &r)?;
169    let auditor_ct = p
170        .auditor_pubkey
171        .map(|pk| encrypt::encrypt(p.amount, pk, &r))
172        .transpose()?;
173
174    // The holder key + Schnorr proof of knowledge are included only on the first
175    // convert (the opt-in). Subsequent converts omit both.
176    let (holder_encryption_key, zk_proof) = if p.register_key {
177        let ctx = context::convert(
178            &account_id(p.account)?,
179            &issuance_id(p.issuance_id_hex)?,
180            p.sequence,
181        )?;
182        let proof = prove::convert(p.holder_privkey, p.holder_pubkey, &ctx)?;
183        (
184            Some(Cow::Owned(upper_hex(p.holder_pubkey.as_bytes()))),
185            Some(Cow::Owned(upper_hex(proof.as_bytes()))),
186        )
187    } else {
188        (None, None)
189    };
190
191    Ok(ConfidentialMPTConvert {
192        common_fields: common(
193            p.account,
194            TransactionType::ConfidentialMPTConvert,
195            p.sequence,
196        ),
197        mptoken_issuance_id: Cow::Owned(p.issuance_id_hex.to_string()),
198        mpt_amount: Cow::Owned(p.amount.to_string()),
199        holder_encrypted_amount: Cow::Owned(upper_hex(holder_ct.as_bytes())),
200        issuer_encrypted_amount: Cow::Owned(upper_hex(issuer_ct.as_bytes())),
201        blinding_factor: Cow::Owned(upper_hex(r.as_bytes())),
202        holder_encryption_key,
203        auditor_encrypted_amount: auditor_ct.map(|ct| Cow::Owned(upper_hex(ct.as_bytes()))),
204        zk_proof,
205    })
206}
207
208/// Inputs for [`assemble_send`].
209pub struct SendParams<'a> {
210    /// The sender's classic address.
211    pub sender_account: &'a str,
212    /// The receiver's classic address.
213    pub destination_account: &'a str,
214    /// Optional `DestinationTag` (e.g. for a hosted/exchange receiver).
215    pub destination_tag: Option<u32>,
216    /// 48-char hex `MPTokenIssuanceID`.
217    pub issuance_id_hex: &'a str,
218    /// The sender's account `Sequence`.
219    pub sequence: u32,
220    /// The sender's on-ledger `ConfidentialBalanceVersion`.
221    pub version: u32,
222    /// The confidential amount to send.
223    pub amount: u64,
224    /// The sender's decrypted current spending balance.
225    pub current_balance: u64,
226    /// The sender's on-ledger `ConfidentialBalanceSpending` (132-char hex).
227    pub balance_ciphertext_hex: &'a str,
228    pub sender_privkey: &'a Privkey,
229    pub sender_pubkey: &'a Pubkey,
230    pub destination_pubkey: &'a Pubkey,
231    pub issuer_pubkey: &'a Pubkey,
232    pub auditor_pubkey: Option<&'a Pubkey>,
233    /// XLS-70 `CredentialIDs` presented to satisfy the destination's
234    /// `DepositPreauth` / credential-based authorization, if it requires one.
235    /// Each entry is a credential's 64-char hex ledger index; `None` (or an empty
236    /// slice) omits the field.
237    pub credential_ids: Option<&'a [&'a str]>,
238}
239
240/// Assemble a `ConfidentialMPTSend` (confidential transfer).
241pub fn assemble_send(p: SendParams<'_>) -> Result<ConfidentialMPTSend<'static>> {
242    if p.amount > p.current_balance {
243        return Err(ConfidentialAssemblyError::InsufficientBalance {
244            amount: p.amount,
245            balance: p.current_balance,
246        });
247    }
248    let tx_r = encrypt::random_blinding_factor()?;
249    let sender_ct = encrypt::encrypt(p.amount, p.sender_pubkey, &tx_r)?;
250    let dest_ct = encrypt::encrypt(p.amount, p.destination_pubkey, &tx_r)?;
251    let issuer_ct = encrypt::encrypt(p.amount, p.issuer_pubkey, &tx_r)?;
252    let auditor_ct = p
253        .auditor_pubkey
254        .map(|pk| encrypt::encrypt(p.amount, pk, &tx_r))
255        .transpose()?;
256    let amount_commitment = commit::pedersen(p.amount, &tx_r)?;
257
258    let balance_blinding = encrypt::random_blinding_factor()?;
259    let balance_commitment = commit::pedersen(p.current_balance, &balance_blinding)?;
260    let balance_ciphertext = ciphertext_from_hex(p.balance_ciphertext_hex)?;
261
262    let ctx = context::send(
263        &account_id(p.sender_account)?,
264        &issuance_id(p.issuance_id_hex)?,
265        p.sequence,
266        &account_id(p.destination_account)?,
267        p.version,
268    )?;
269
270    let auditor_participant = match (p.auditor_pubkey, &auditor_ct) {
271        (Some(pk), Some(ct)) => Some(prove::Participant {
272            pubkey: pk,
273            ciphertext: ct,
274        }),
275        _ => None,
276    };
277    let proof = prove::send(prove::SendProofParams {
278        sender_privkey: p.sender_privkey,
279        sender_pubkey: p.sender_pubkey,
280        amount: p.amount,
281        current_balance: p.current_balance,
282        tx_blinding_factor: &tx_r,
283        context_hash: &ctx,
284        amount_commitment: &amount_commitment,
285        balance_commitment: &balance_commitment,
286        balance_blinding: &balance_blinding,
287        balance_ciphertext: &balance_ciphertext,
288        sender: prove::Participant {
289            pubkey: p.sender_pubkey,
290            ciphertext: &sender_ct,
291        },
292        destination: prove::Participant {
293            pubkey: p.destination_pubkey,
294            ciphertext: &dest_ct,
295        },
296        issuer: prove::Participant {
297            pubkey: p.issuer_pubkey,
298            ciphertext: &issuer_ct,
299        },
300        auditor: auditor_participant,
301    })?;
302
303    Ok(ConfidentialMPTSend {
304        common_fields: common(
305            p.sender_account,
306            TransactionType::ConfidentialMPTSend,
307            p.sequence,
308        ),
309        destination: Cow::Owned(p.destination_account.to_string()),
310        destination_tag: p.destination_tag,
311        mptoken_issuance_id: Cow::Owned(p.issuance_id_hex.to_string()),
312        sender_encrypted_amount: Cow::Owned(upper_hex(sender_ct.as_bytes())),
313        destination_encrypted_amount: Cow::Owned(upper_hex(dest_ct.as_bytes())),
314        issuer_encrypted_amount: Cow::Owned(upper_hex(issuer_ct.as_bytes())),
315        amount_commitment: Cow::Owned(upper_hex(amount_commitment.as_bytes())),
316        balance_commitment: Cow::Owned(upper_hex(balance_commitment.as_bytes())),
317        zk_proof: Cow::Owned(upper_hex(proof.as_bytes())),
318        auditor_encrypted_amount: auditor_ct.map(|ct| Cow::Owned(upper_hex(ct.as_bytes()))),
319        credential_ids: p.credential_ids.filter(|ids| !ids.is_empty()).map(|ids| {
320            ids.iter()
321                .map(|id| Cow::Owned(id.to_string()))
322                .collect::<Vec<_>>()
323        }),
324    })
325}
326
327/// Inputs for [`assemble_convert_back`].
328pub struct ConvertBackParams<'a> {
329    /// The holder's classic address.
330    pub account: &'a str,
331    /// 48-char hex `MPTokenIssuanceID`.
332    pub issuance_id_hex: &'a str,
333    /// The holder's account `Sequence`.
334    pub sequence: u32,
335    /// The holder's on-ledger `ConfidentialBalanceVersion`.
336    pub version: u32,
337    /// The confidential amount to convert back to public.
338    pub amount: u64,
339    /// The holder's decrypted current spending balance.
340    pub current_balance: u64,
341    /// The holder's on-ledger `ConfidentialBalanceSpending` (132-char hex).
342    pub balance_ciphertext_hex: &'a str,
343    pub holder_privkey: &'a Privkey,
344    pub holder_pubkey: &'a Pubkey,
345    pub issuer_pubkey: &'a Pubkey,
346    pub auditor_pubkey: Option<&'a Pubkey>,
347}
348
349/// Assemble a `ConfidentialMPTConvertBack` (confidential → public).
350pub fn assemble_convert_back(
351    p: ConvertBackParams<'_>,
352) -> Result<ConfidentialMPTConvertBack<'static>> {
353    if p.amount > p.current_balance {
354        return Err(ConfidentialAssemblyError::InsufficientBalance {
355            amount: p.amount,
356            balance: p.current_balance,
357        });
358    }
359    let r = encrypt::random_blinding_factor()?;
360    let holder_ct = encrypt::encrypt(p.amount, p.holder_pubkey, &r)?;
361    let issuer_ct = encrypt::encrypt(p.amount, p.issuer_pubkey, &r)?;
362    let auditor_ct = p
363        .auditor_pubkey
364        .map(|pk| encrypt::encrypt(p.amount, pk, &r))
365        .transpose()?;
366
367    let balance_blinding = encrypt::random_blinding_factor()?;
368    let balance_commitment = commit::pedersen(p.current_balance, &balance_blinding)?;
369    let balance_ciphertext = ciphertext_from_hex(p.balance_ciphertext_hex)?;
370
371    let ctx = context::convert_back(
372        &account_id(p.account)?,
373        &issuance_id(p.issuance_id_hex)?,
374        p.sequence,
375        p.version,
376    )?;
377    let proof = prove::convert_back(prove::ConvertBackProofParams {
378        holder_privkey: p.holder_privkey,
379        holder_pubkey: p.holder_pubkey,
380        amount: p.amount,
381        current_balance: p.current_balance,
382        context_hash: &ctx,
383        balance_commitment: &balance_commitment,
384        balance_blinding: &balance_blinding,
385        balance_ciphertext: &balance_ciphertext,
386    })?;
387
388    Ok(ConfidentialMPTConvertBack {
389        common_fields: common(
390            p.account,
391            TransactionType::ConfidentialMPTConvertBack,
392            p.sequence,
393        ),
394        mptoken_issuance_id: Cow::Owned(p.issuance_id_hex.to_string()),
395        mpt_amount: Cow::Owned(p.amount.to_string()),
396        holder_encrypted_amount: Cow::Owned(upper_hex(holder_ct.as_bytes())),
397        issuer_encrypted_amount: Cow::Owned(upper_hex(issuer_ct.as_bytes())),
398        blinding_factor: Cow::Owned(upper_hex(r.as_bytes())),
399        balance_commitment: Cow::Owned(upper_hex(balance_commitment.as_bytes())),
400        zk_proof: Cow::Owned(upper_hex(proof.as_bytes())),
401        auditor_encrypted_amount: auditor_ct.map(|ct| Cow::Owned(upper_hex(ct.as_bytes()))),
402    })
403}
404
405/// Inputs for [`assemble_clawback`].
406pub struct ClawbackParams<'a> {
407    /// The issuer's classic address (transaction sender).
408    pub issuer_account: &'a str,
409    /// The holder whose balance is being clawed back.
410    pub holder_account: &'a str,
411    /// 48-char hex `MPTokenIssuanceID`.
412    pub issuance_id_hex: &'a str,
413    /// The issuer's account `Sequence`.
414    pub sequence: u32,
415    /// The plaintext amount being reclaimed.
416    pub amount: u64,
417    pub issuer_privkey: &'a Privkey,
418    pub issuer_pubkey: &'a Pubkey,
419    /// The holder's on-ledger `IssuerEncryptedBalance` (132-char hex).
420    pub issuer_encrypted_balance_hex: &'a str,
421}
422
423/// Assemble a `ConfidentialMPTClawback` (issuer reclaims a holder's balance).
424pub fn assemble_clawback(p: ClawbackParams<'_>) -> Result<ConfidentialMPTClawback<'static>> {
425    let ctx = context::clawback(
426        &account_id(p.issuer_account)?,
427        &issuance_id(p.issuance_id_hex)?,
428        p.sequence,
429        &account_id(p.holder_account)?,
430    )?;
431    let proof = prove::clawback(
432        p.issuer_privkey,
433        p.issuer_pubkey,
434        &ctx,
435        p.amount,
436        &ciphertext_from_hex(p.issuer_encrypted_balance_hex)?,
437    )?;
438
439    Ok(ConfidentialMPTClawback {
440        common_fields: common(
441            p.issuer_account,
442            TransactionType::ConfidentialMPTClawback,
443            p.sequence,
444        ),
445        holder: Cow::Owned(p.holder_account.to_string()),
446        mptoken_issuance_id: Cow::Owned(p.issuance_id_hex.to_string()),
447        mpt_amount: Cow::Owned(p.amount.to_string()),
448        zk_proof: Cow::Owned(upper_hex(proof.as_bytes())),
449    })
450}
451
452/// Assemble a `ConfidentialMPTMergeInbox` (merge inbox into spending balance).
453/// No cryptographic material is required.
454pub fn assemble_merge_inbox(
455    account: &str,
456    issuance_id_hex: &str,
457    sequence: u32,
458) -> ConfidentialMPTMergeInbox<'static> {
459    ConfidentialMPTMergeInbox {
460        common_fields: common(
461            account,
462            TransactionType::ConfidentialMPTMergeInbox,
463            sequence,
464        ),
465        mptoken_issuance_id: Cow::Owned(issuance_id_hex.to_string()),
466    }
467}
468
469// ─────────────────────────────────────────────────────────────────────────────
470// Client-querying convenience layer.
471//
472// The `assemble_*` functions above are pure — the caller supplies all
473// ledger-derived state. These `prepare_confidential_*` wrappers fetch that state
474// from an async client (account sequence, the on-ledger MPToken's confidential
475// balance ciphertext + version, decrypting the current balance), mirroring
476// xrpl-py's `prepare_confidential_*` layer. The fee is left to autofill.
477//
478// This layer needs `asynch::account` (`helpers`) and the `XRPLAsyncClient` trait
479// in `asynch::clients` (`json-rpc`/`websocket`), plus a runtime for the retry
480// sleeps. Those aren't part of `confidential-mpt` itself (they'd force a runtime
481// choice on the caller), so gate the module on them — the pure `assemble_*`
482// layer above stays usable with `confidential-mpt` alone.
483// ─────────────────────────────────────────────────────────────────────────────
484#[cfg(all(feature = "helpers", any(feature = "json-rpc", feature = "websocket")))]
485pub use prepare::*;
486
487#[cfg(all(feature = "helpers", any(feature = "json-rpc", feature = "websocket")))]
488mod prepare {
489    use alloc::format;
490    use alloc::string::ToString;
491
492    use serde_json::Value;
493
494    use super::{
495        assemble_clawback, assemble_convert, assemble_convert_back, assemble_merge_inbox,
496        assemble_send, decrypt_balance_in_range, ClawbackParams, ConfidentialAssemblyError,
497        ConvertBackParams, ConvertParams, Result, SendParams,
498    };
499    use crate::asynch::account::get_next_valid_seq_number;
500    use crate::asynch::clients::XRPLAsyncClient;
501    use crate::models::requests::account_objects::{AccountObjectType, AccountObjects};
502    use crate::models::requests::{CommonFields, Marker, RequestMethod};
503    use crate::models::results::account_objects::AccountObjects as AccountObjectsResult;
504    use crate::models::transactions::confidential_mpt_clawback::ConfidentialMPTClawback;
505    use crate::models::transactions::confidential_mpt_convert::ConfidentialMPTConvert;
506    use crate::models::transactions::confidential_mpt_convert_back::ConfidentialMPTConvertBack;
507    use crate::models::transactions::confidential_mpt_merge_inbox::ConfidentialMPTMergeInbox;
508    use crate::models::transactions::confidential_mpt_send::ConfidentialMPTSend;
509    use crate::mpt_crypto::{Privkey, Pubkey};
510
511    async fn fetch_sequence<C: XRPLAsyncClient>(client: &C, account: &str) -> Result<u32> {
512        get_next_valid_seq_number(account.to_string().into(), client, None)
513            .await
514            .map_err(|e| ConfidentialAssemblyError::Ledger(format!("fetch sequence failed: {e}")))
515    }
516
517    /// Read `account`'s MPToken for `issuance_id_hex` as raw JSON (the typed
518    /// ledger objects do not yet expose the confidential fields).
519    async fn read_mptoken<C: XRPLAsyncClient>(
520        client: &C,
521        account: &str,
522        issuance_id_hex: &str,
523    ) -> Result<Value> {
524        // Page through account_objects following `marker`: an account with many
525        // objects returns them across pages, so the MPToken we want may not be
526        // on the first page. Own the marker (into_owned) so it survives past the
527        // response it came from, into the next request.
528        let mut marker: Option<Marker<'static>> = None;
529        loop {
530            let request = AccountObjects {
531                common_fields: CommonFields {
532                    command: RequestMethod::AccountObjects,
533                    id: None,
534                },
535                account: account.to_string().into(),
536                ledger_lookup: None,
537                r#type: Some(AccountObjectType::Mptoken),
538                deletion_blockers_only: None,
539                limit: None,
540                marker,
541            };
542            let response = client.request(request.into()).await.map_err(|e| {
543                ConfidentialAssemblyError::Ledger(format!("account_objects request failed: {e}"))
544            })?;
545            let objects = AccountObjectsResult::try_from(response).map_err(|e| {
546                ConfidentialAssemblyError::Ledger(format!("could not parse account_objects: {e}"))
547            })?;
548            // Case-insensitive: the ledger returns uppercase hex, but a caller
549            // may pass lowercase (e.g. from `hex::encode`); the rest of this
550            // module parses issuance IDs case-insensitively via `hex::decode`.
551            if let Some(obj) = objects.account_objects.iter().find(|o| {
552                o.get("MPTokenIssuanceID")
553                    .and_then(Value::as_str)
554                    .is_some_and(|s| s.eq_ignore_ascii_case(issuance_id_hex))
555            }) {
556                return Ok(obj.clone());
557            }
558            match objects.marker {
559                Some(Marker::Str(s)) => marker = Some(Marker::Str(s.into_owned().into())),
560                Some(Marker::Int(i)) => marker = Some(Marker::Int(i)),
561                Some(Marker::Sequence(sq)) => marker = Some(Marker::Sequence(sq)),
562                None => {
563                    return Err(ConfidentialAssemblyError::Ledger(format!(
564                        "no MPToken for issuance {issuance_id_hex} owned by {account}"
565                    )))
566                }
567            }
568        }
569    }
570
571    fn field_str<'a>(node: &'a Value, field: &str) -> Result<&'a str> {
572        node.get(field).and_then(Value::as_str).ok_or_else(|| {
573            ConfidentialAssemblyError::Ledger(format!("MPToken is missing field {field}"))
574        })
575    }
576
577    fn balance_version(node: &Value) -> u32 {
578        node.get("ConfidentialBalanceVersion")
579            .and_then(Value::as_u64)
580            .unwrap_or(0) as u32
581    }
582
583    /// Prepare a `ConfidentialMPTConvert`, fetching the account sequence and
584    /// auto-detecting whether this is the first convert (which registers the
585    /// holder key) from the on-ledger MPToken.
586    #[allow(clippy::too_many_arguments)]
587    pub async fn prepare_confidential_convert<C: XRPLAsyncClient>(
588        client: &C,
589        account: &str,
590        issuance_id_hex: &str,
591        amount: u64,
592        issuer_pubkey: &Pubkey,
593        holder_privkey: &Privkey,
594        holder_pubkey: &Pubkey,
595        auditor_pubkey: Option<&Pubkey>,
596    ) -> Result<ConfidentialMPTConvert<'static>> {
597        let sequence = fetch_sequence(client, account).await?;
598        let node = read_mptoken(client, account, issuance_id_hex).await?;
599        // First convert (opt-in) registers the holder key; later ones must not
600        // (rippled returns tecDUPLICATE).
601        let register_key = node
602            .get("HolderEncryptionKey")
603            .and_then(Value::as_str)
604            .is_none();
605        assemble_convert(ConvertParams {
606            account,
607            issuance_id_hex,
608            sequence,
609            amount,
610            issuer_pubkey,
611            holder_privkey,
612            holder_pubkey,
613            auditor_pubkey,
614            register_key,
615        })
616    }
617
618    /// Prepare a `ConfidentialMPTSend`, fetching the sender's sequence + on-ledger
619    /// spending balance and decrypting it. `max_balance` bounds the decrypt
620    /// discrete-log search (cost is O(max_balance)); pass the issuance's
621    /// outstanding amount or a known upper bound.
622    #[allow(clippy::too_many_arguments)]
623    pub async fn prepare_confidential_send<C: XRPLAsyncClient>(
624        client: &C,
625        sender_account: &str,
626        destination_account: &str,
627        destination_tag: Option<u32>,
628        issuance_id_hex: &str,
629        amount: u64,
630        max_balance: u64,
631        sender_privkey: &Privkey,
632        sender_pubkey: &Pubkey,
633        destination_pubkey: &Pubkey,
634        issuer_pubkey: &Pubkey,
635        auditor_pubkey: Option<&Pubkey>,
636        credential_ids: Option<&[&str]>,
637    ) -> Result<ConfidentialMPTSend<'static>> {
638        let sequence = fetch_sequence(client, sender_account).await?;
639        let node = read_mptoken(client, sender_account, issuance_id_hex).await?;
640        let balance_ciphertext_hex = field_str(&node, "ConfidentialBalanceSpending")?.to_string();
641        let version = balance_version(&node);
642        let current_balance =
643            decrypt_balance_in_range(&balance_ciphertext_hex, sender_privkey, 0, max_balance)?;
644        assemble_send(SendParams {
645            sender_account,
646            destination_account,
647            destination_tag,
648            issuance_id_hex,
649            sequence,
650            version,
651            amount,
652            current_balance,
653            balance_ciphertext_hex: &balance_ciphertext_hex,
654            sender_privkey,
655            sender_pubkey,
656            destination_pubkey,
657            issuer_pubkey,
658            auditor_pubkey,
659            credential_ids,
660        })
661    }
662
663    /// Prepare a `ConfidentialMPTConvertBack`, fetching + decrypting the holder's
664    /// on-ledger spending balance. See `prepare_confidential_send` re `max_balance`.
665    #[allow(clippy::too_many_arguments)]
666    pub async fn prepare_confidential_convert_back<C: XRPLAsyncClient>(
667        client: &C,
668        account: &str,
669        issuance_id_hex: &str,
670        amount: u64,
671        max_balance: u64,
672        holder_privkey: &Privkey,
673        holder_pubkey: &Pubkey,
674        issuer_pubkey: &Pubkey,
675        auditor_pubkey: Option<&Pubkey>,
676    ) -> Result<ConfidentialMPTConvertBack<'static>> {
677        let sequence = fetch_sequence(client, account).await?;
678        let node = read_mptoken(client, account, issuance_id_hex).await?;
679        let balance_ciphertext_hex = field_str(&node, "ConfidentialBalanceSpending")?.to_string();
680        let version = balance_version(&node);
681        let current_balance =
682            decrypt_balance_in_range(&balance_ciphertext_hex, holder_privkey, 0, max_balance)?;
683        assemble_convert_back(ConvertBackParams {
684            account,
685            issuance_id_hex,
686            sequence,
687            version,
688            amount,
689            current_balance,
690            balance_ciphertext_hex: &balance_ciphertext_hex,
691            holder_privkey,
692            holder_pubkey,
693            issuer_pubkey,
694            auditor_pubkey,
695        })
696    }
697
698    /// Prepare a `ConfidentialMPTClawback`, fetching the issuer's sequence and the
699    /// holder's on-ledger `IssuerEncryptedBalance`.
700    #[allow(clippy::too_many_arguments)]
701    pub async fn prepare_confidential_clawback<C: XRPLAsyncClient>(
702        client: &C,
703        issuer_account: &str,
704        holder_account: &str,
705        issuance_id_hex: &str,
706        amount: u64,
707        issuer_privkey: &Privkey,
708        issuer_pubkey: &Pubkey,
709    ) -> Result<ConfidentialMPTClawback<'static>> {
710        let sequence = fetch_sequence(client, issuer_account).await?;
711        let node = read_mptoken(client, holder_account, issuance_id_hex).await?;
712        let issuer_encrypted_balance_hex = field_str(&node, "IssuerEncryptedBalance")?.to_string();
713        assemble_clawback(ClawbackParams {
714            issuer_account,
715            holder_account,
716            issuance_id_hex,
717            sequence,
718            amount,
719            issuer_privkey,
720            issuer_pubkey,
721            issuer_encrypted_balance_hex: &issuer_encrypted_balance_hex,
722        })
723    }
724
725    /// Prepare a `ConfidentialMPTMergeInbox`, fetching the account sequence.
726    pub async fn prepare_confidential_merge_inbox<C: XRPLAsyncClient>(
727        client: &C,
728        account: &str,
729        issuance_id_hex: &str,
730    ) -> Result<ConfidentialMPTMergeInbox<'static>> {
731        let sequence = fetch_sequence(client, account).await?;
732        Ok(assemble_merge_inbox(account, issuance_id_hex, sequence))
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use crate::models::Model;
740    use crate::mpt_crypto::keypair;
741
742    const ACCOUNT: &str = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW";
743    const ISSUANCE: &str = "0000012FFD9EE5DA93AC614B4DB94D7E0FCE415CA51BED47";
744
745    #[test]
746    fn decrypt_balance_roundtrip() {
747        let (sk, pk) = keypair::generate().unwrap();
748        let r = encrypt::random_blinding_factor().unwrap();
749        let ct = encrypt::encrypt(4242, &pk, &r).unwrap();
750        assert_eq!(
751            decrypt_balance(&upper_hex(ct.as_bytes()), &sk).unwrap(),
752            4242
753        );
754    }
755
756    #[test]
757    fn convert_first_registers_key_and_validates() {
758        let (_issuer_sk, issuer_pk) = keypair::generate().unwrap();
759        let (holder_sk, holder_pk) = keypair::generate().unwrap();
760        let tx = assemble_convert(ConvertParams {
761            account: ACCOUNT,
762            issuance_id_hex: ISSUANCE,
763            sequence: 5,
764            amount: 1000,
765            issuer_pubkey: &issuer_pk,
766            holder_privkey: &holder_sk,
767            holder_pubkey: &holder_pk,
768            auditor_pubkey: None,
769            register_key: true,
770        })
771        .unwrap();
772        assert!(tx.holder_encryption_key.is_some());
773        assert!(tx.zk_proof.is_some());
774        assert_eq!(tx.common_fields.sequence, Some(5));
775        assert!(tx.validate().is_ok());
776    }
777
778    #[test]
779    fn convert_subsequent_omits_key_and_proof() {
780        let (_issuer_sk, issuer_pk) = keypair::generate().unwrap();
781        let (holder_sk, holder_pk) = keypair::generate().unwrap();
782        let tx = assemble_convert(ConvertParams {
783            account: ACCOUNT,
784            issuance_id_hex: ISSUANCE,
785            sequence: 6,
786            amount: 1000,
787            issuer_pubkey: &issuer_pk,
788            holder_privkey: &holder_sk,
789            holder_pubkey: &holder_pk,
790            auditor_pubkey: None,
791            register_key: false,
792        })
793        .unwrap();
794        assert!(tx.holder_encryption_key.is_none());
795        assert!(tx.zk_proof.is_none());
796    }
797
798    #[test]
799    fn convert_with_auditor_sets_auditor_ciphertext() {
800        let (_issuer_sk, issuer_pk) = keypair::generate().unwrap();
801        let (holder_sk, holder_pk) = keypair::generate().unwrap();
802        let (_auditor_sk, auditor_pk) = keypair::generate().unwrap();
803        let tx = assemble_convert(ConvertParams {
804            account: ACCOUNT,
805            issuance_id_hex: ISSUANCE,
806            sequence: 5,
807            amount: 1000,
808            issuer_pubkey: &issuer_pk,
809            holder_privkey: &holder_sk,
810            holder_pubkey: &holder_pk,
811            auditor_pubkey: Some(&auditor_pk),
812            register_key: true,
813        })
814        .unwrap();
815        assert!(tx.auditor_encrypted_amount.is_some());
816    }
817
818    #[test]
819    fn merge_inbox_builds_and_validates() {
820        let tx = assemble_merge_inbox(ACCOUNT, ISSUANCE, 7);
821        assert_eq!(tx.common_fields.sequence, Some(7));
822        assert!(tx.validate().is_ok());
823    }
824
825    #[test]
826    fn invalid_address_is_rejected() {
827        let (_sk, pk) = keypair::generate().unwrap();
828        let (holder_sk, holder_pk) = keypair::generate().unwrap();
829        let err = assemble_convert(ConvertParams {
830            account: "not_a_valid_address",
831            issuance_id_hex: ISSUANCE,
832            sequence: 5,
833            amount: 1000,
834            issuer_pubkey: &pk,
835            holder_privkey: &holder_sk,
836            holder_pubkey: &holder_pk,
837            auditor_pubkey: None,
838            register_key: true,
839        });
840        assert!(matches!(
841            err,
842            Err(ConfidentialAssemblyError::InvalidAddress(_))
843        ));
844    }
845
846    #[test]
847    fn assemble_send_rejects_overspend() {
848        let (sk, pk) = keypair::generate().unwrap();
849        let (_dsk, dpk) = keypair::generate().unwrap();
850        // amount (100) > current_balance (50): rejected before any proof work
851        // (the check runs first, so the ciphertext hex is never parsed).
852        let err = assemble_send(SendParams {
853            sender_account: ACCOUNT,
854            destination_account: ACCOUNT,
855            destination_tag: None,
856            issuance_id_hex: ISSUANCE,
857            sequence: 1,
858            version: 0,
859            amount: 100,
860            current_balance: 50,
861            balance_ciphertext_hex: "",
862            sender_privkey: &sk,
863            sender_pubkey: &pk,
864            destination_pubkey: &dpk,
865            issuer_pubkey: &pk,
866            auditor_pubkey: None,
867            credential_ids: None,
868        })
869        .unwrap_err();
870        assert!(matches!(
871            err,
872            ConfidentialAssemblyError::InsufficientBalance {
873                amount: 100,
874                balance: 50
875            }
876        ));
877    }
878
879    #[test]
880    fn send_threads_credential_ids() {
881        // XLS-70 CredentialIDs supplied on SendParams are carried onto the tx.
882        let (sk, pk) = keypair::generate().unwrap();
883        let (_dsk, dpk) = keypair::generate().unwrap();
884        let (_isk, ipk) = keypair::generate().unwrap();
885        let r = encrypt::random_blinding_factor().unwrap();
886        let balance_hex = upper_hex(encrypt::encrypt(1000, &pk, &r).unwrap().as_bytes());
887        let cred_a = "AB".repeat(32); // 64-hex credential ledger index
888        let cred_b = "CD".repeat(32);
889        let creds = [cred_a.as_str(), cred_b.as_str()];
890
891        let tx = assemble_send(SendParams {
892            sender_account: ACCOUNT,
893            destination_account: ACCOUNT,
894            destination_tag: None,
895            issuance_id_hex: ISSUANCE,
896            sequence: 1,
897            version: 0,
898            amount: 10,
899            current_balance: 1000,
900            balance_ciphertext_hex: &balance_hex,
901            sender_privkey: &sk,
902            sender_pubkey: &pk,
903            destination_pubkey: &dpk,
904            issuer_pubkey: &ipk,
905            auditor_pubkey: None,
906            credential_ids: Some(&creds),
907        })
908        .unwrap();
909
910        let got = tx.credential_ids.expect("credential_ids threaded through");
911        assert_eq!(got.len(), 2);
912        assert_eq!(got[0], cred_a);
913        assert_eq!(got[1], cred_b);
914
915        // An empty slice is treated as absent (an empty CredentialIDs list is
916        // rejected on-ledger), so the field is omitted.
917        let empty: [&str; 0] = [];
918        let tx_empty = assemble_send(SendParams {
919            sender_account: ACCOUNT,
920            destination_account: ACCOUNT,
921            destination_tag: None,
922            issuance_id_hex: ISSUANCE,
923            sequence: 1,
924            version: 0,
925            amount: 10,
926            current_balance: 1000,
927            balance_ciphertext_hex: &balance_hex,
928            sender_privkey: &sk,
929            sender_pubkey: &pk,
930            destination_pubkey: &dpk,
931            issuer_pubkey: &ipk,
932            auditor_pubkey: None,
933            credential_ids: Some(&empty),
934        })
935        .unwrap();
936        assert!(tx_empty.credential_ids.is_none());
937    }
938
939    #[test]
940    fn assemble_send_happy_path() {
941        // A fully-formed send: correct field shapes, the destination ciphertext
942        // decrypts to the sent amount, and the assembled model validates.
943        let (sk, pk) = keypair::generate().unwrap();
944        let (dsk, dpk) = keypair::generate().unwrap();
945        let (_isk, ipk) = keypair::generate().unwrap();
946        let r = encrypt::random_blinding_factor().unwrap();
947        let balance_hex = upper_hex(encrypt::encrypt(1000, &pk, &r).unwrap().as_bytes());
948
949        // A second real address, distinct from the sender and from the issuer
950        // embedded in ISSUANCE, so the self-send / issuer-role bans pass.
951        const DESTINATION: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh";
952
953        let tx = assemble_send(SendParams {
954            sender_account: ACCOUNT,
955            destination_account: DESTINATION,
956            destination_tag: Some(42),
957            issuance_id_hex: ISSUANCE,
958            sequence: 7,
959            version: 3,
960            amount: 250,
961            current_balance: 1000,
962            balance_ciphertext_hex: &balance_hex,
963            sender_privkey: &sk,
964            sender_pubkey: &pk,
965            destination_pubkey: &dpk,
966            issuer_pubkey: &ipk,
967            auditor_pubkey: None,
968            credential_ids: None,
969        })
970        .unwrap();
971
972        assert_eq!(tx.destination.as_ref(), DESTINATION);
973        assert_eq!(tx.destination_tag, Some(42));
974        assert_eq!(tx.common_fields.sequence, Some(7));
975        assert_eq!(tx.mptoken_issuance_id.as_ref(), ISSUANCE);
976        // ElGamal ciphertexts are 132 hex chars; Pedersen commitments 66; the
977        // composite Send proof is 946 bytes = 1892 hex chars.
978        assert_eq!(tx.sender_encrypted_amount.len(), 132);
979        assert_eq!(tx.destination_encrypted_amount.len(), 132);
980        assert_eq!(tx.issuer_encrypted_amount.len(), 132);
981        assert_eq!(tx.amount_commitment.len(), 66);
982        assert_eq!(tx.balance_commitment.len(), 66);
983        assert_eq!(tx.zk_proof.len(), 1892);
984        assert!(tx.auditor_encrypted_amount.is_none());
985
986        // The destination ciphertext really encrypts the sent amount.
987        assert_eq!(
988            decrypt_balance(tx.destination_encrypted_amount.as_ref(), &dsk).unwrap(),
989            250
990        );
991        // The assembled transaction is a valid model.
992        assert!(tx.validate().is_ok());
993    }
994}