1use 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#[derive(Debug, Error)]
41pub enum ConfidentialAssemblyError {
42 #[error("mpt-crypto error: {0}")]
44 Crypto(#[from] crate::mpt_crypto::Error),
45 #[error("invalid classic address: {0}")]
47 InvalidAddress(String),
48 #[error("invalid MPTokenIssuanceID (expected 48-char hex): {0}")]
50 InvalidIssuanceId(String),
51 #[error("invalid ciphertext (expected 132-char hex): {0}")]
53 InvalidCiphertext(String),
54 #[error("ledger query error: {0}")]
57 Ledger(String),
58 #[error("amount {amount} exceeds confidential spending balance {balance}")]
62 InsufficientBalance {
63 amount: u64,
65 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 sequence: Some(sequence),
110 ..Default::default()
111 }
112}
113
114pub 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
123pub 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
140pub struct ConvertParams<'a> {
142 pub account: &'a str,
144 pub issuance_id_hex: &'a str,
146 pub sequence: u32,
148 pub amount: u64,
150 pub issuer_pubkey: &'a Pubkey,
152 pub holder_privkey: &'a Privkey,
154 pub holder_pubkey: &'a Pubkey,
155 pub auditor_pubkey: Option<&'a Pubkey>,
158 pub register_key: bool,
162}
163
164pub 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 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
208pub struct SendParams<'a> {
210 pub sender_account: &'a str,
212 pub destination_account: &'a str,
214 pub destination_tag: Option<u32>,
216 pub issuance_id_hex: &'a str,
218 pub sequence: u32,
220 pub version: u32,
222 pub amount: u64,
224 pub current_balance: u64,
226 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 pub credential_ids: Option<&'a [&'a str]>,
238}
239
240pub 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
327pub struct ConvertBackParams<'a> {
329 pub account: &'a str,
331 pub issuance_id_hex: &'a str,
333 pub sequence: u32,
335 pub version: u32,
337 pub amount: u64,
339 pub current_balance: u64,
341 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
349pub 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
405pub struct ClawbackParams<'a> {
407 pub issuer_account: &'a str,
409 pub holder_account: &'a str,
411 pub issuance_id_hex: &'a str,
413 pub sequence: u32,
415 pub amount: u64,
417 pub issuer_privkey: &'a Privkey,
418 pub issuer_pubkey: &'a Pubkey,
419 pub issuer_encrypted_balance_hex: &'a str,
421}
422
423pub 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
452pub 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#[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 async fn read_mptoken<C: XRPLAsyncClient>(
520 client: &C,
521 account: &str,
522 issuance_id_hex: &str,
523 ) -> Result<Value> {
524 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 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 #[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 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 #[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 #[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 #[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 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 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 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); 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 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 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 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 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 assert_eq!(
988 decrypt_balance(tx.destination_encrypted_amount.as_ref(), &dsk).unwrap(),
989 250
990 );
991 assert!(tx.validate().is_ok());
993 }
994}