1use aes_gcm::{
32 aead::{Aead, KeyInit},
33 Aes256Gcm, Key, Nonce,
34};
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37use truthlinked_core::pq_execution::AccountId;
38use truthlinked_governance::params as gp;
39use truthlinked_runtime::types::{CellUpdate, StateDiff};
40use truthlinked_staking::StakingState;
41
42use crate::McpStateView;
43
44pub const PRIVATE_BALANCE_MIN_STAKE: u64 = 100_000 * 1_000_000_000u64;
50
51pub const MAX_PRIVATE_BALANCE: u128 = (1u128 << 96) - 1;
52pub const MAX_TRANSFER_AMOUNT: u128 = (1u128 << 64) - 1;
53pub const MAX_FEE_AMOUNT: u128 = (1u128 << 32) - 1;
54
55const AES_NONCE_LEN: usize = 12;
56pub const CIPHERTEXT_LEN: usize = 44;
58
59pub trait PrivateBalanceStateView: McpStateView {
66 fn staking(&self) -> &StakingState;
67}
68
69pub mod pb_keys {
74 use crate::registry_keys::blake3_key;
75
76 pub const COMMITMENT: [u8; 32] = key(b"pb:c");
78 pub const CIPHER_LO: [u8; 32] = key(b"pb:l");
80 pub const CIPHER_HI: [u8; 32] = key(b"pb:h");
82 pub const COMMIT_NONCE: [u8; 32] = key(b"pb:n");
84 pub const OWNER: [u8; 32] = key(b"pb:o");
86 pub const AGENT: [u8; 32] = key(b"pb:a");
88 pub const LOCKED: [u8; 32] = key(b"pb:L");
90 pub const STAKE_VERIFIED: [u8; 32] = key(b"pb:sv");
92 pub const TOTAL_DEPOSITED: [u8; 32] = key(b"pb:td");
94 pub const TOTAL_WITHDRAWN: [u8; 32] = key(b"pb:tw");
96 pub const TOTAL_FEES: [u8; 32] = key(b"pb:tf");
98 pub const TOTAL_CT_OUT: [u8; 32] = key(b"pb:to");
100
101 pub fn cell_for_agent(agent_id: &[u8; 32]) -> [u8; 32] {
103 blake3_key(b"pb:cell:", agent_id)
104 }
105
106 const fn key(tag: &[u8]) -> [u8; 32] {
107 let mut k = [0u8; 32];
108 let mut i = 0;
109 while i < tag.len() && i < 32 {
110 k[i] = tag[i];
111 i += 1;
112 }
113 k
114 }
115}
116
117pub fn compute_commitment(balance: u128, nonce: u128, ciphertext: &[u8]) -> [u8; 32] {
128 let ct_hash = blake3::hash(ciphertext);
129 let mut ct_hash_lo16 = [0u8; 16];
130 ct_hash_lo16.copy_from_slice(&ct_hash.as_bytes()[..16]);
131 let digest = crate::zk_transfer::rescue_commit(balance, nonce, &ct_hash_lo16);
132 crate::zk_transfer::digest_to_bytes(&digest)
133}
134
135pub fn verify_commitment(
136 commitment: &[u8; 32],
137 balance: u128,
138 nonce: u128,
139 ciphertext: &[u8],
140) -> Result<(), String> {
141 let expected = compute_commitment(balance, nonce, ciphertext);
142 if expected != *commitment {
143 return Err("Commitment mismatch: balance, nonce, or ciphertext does not match".into());
144 }
145 Ok(())
146}
147
148pub fn derive_aes_key(seed: &[u8]) -> [u8; 32] {
153 blake3::derive_key("truthlinked:private_balance:aes256gcm:v2", seed)
154}
155
156pub fn encrypt_balance(
157 balance: u128,
158 aes_key: &[u8; 32],
159 enc_nonce: &[u8; AES_NONCE_LEN],
160) -> Result<Vec<u8>, String> {
161 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(aes_key));
162 let ct = cipher
163 .encrypt(Nonce::from_slice(enc_nonce), balance.to_le_bytes().as_ref())
164 .map_err(|e| format!("Encryption failed: {e}"))?;
165 let mut out = Vec::with_capacity(CIPHERTEXT_LEN);
166 out.extend_from_slice(enc_nonce);
167 out.extend_from_slice(&ct);
168 Ok(out)
169}
170
171pub fn decrypt_balance(ciphertext: &[u8], aes_key: &[u8; 32]) -> Result<u128, String> {
172 if ciphertext.len() != CIPHERTEXT_LEN {
173 return Err(format!(
174 "Ciphertext must be {CIPHERTEXT_LEN} bytes, got {}",
175 ciphertext.len()
176 ));
177 }
178 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(aes_key));
179 let pt = cipher
180 .decrypt(
181 Nonce::from_slice(&ciphertext[..AES_NONCE_LEN]),
182 &ciphertext[AES_NONCE_LEN..],
183 )
184 .map_err(|_| "Decryption failed: wrong key or corrupted ciphertext".to_string())?;
185 if pt.len() != 16 {
186 return Err("Decrypted plaintext wrong length".into());
187 }
188 let mut buf = [0u8; 16];
189 buf.copy_from_slice(&pt);
190 Ok(u128::from_le_bytes(buf))
191}
192
193pub fn read_and_decrypt_balance(
194 cell: &truthlinked_runtime::cells::CellAccount,
195 aes_key: &[u8; 32],
196) -> Result<u128, String> {
197 let lo = cell
198 .storage
199 .get(&pb_keys::CIPHER_LO)
200 .copied()
201 .unwrap_or([0u8; 32]);
202 let hi = cell
203 .storage
204 .get(&pb_keys::CIPHER_HI)
205 .copied()
206 .unwrap_or([0u8; 32]);
207 let mut ct = Vec::with_capacity(CIPHERTEXT_LEN);
208 ct.extend_from_slice(&lo);
209 ct.extend_from_slice(&hi[..12]);
210 decrypt_balance(&ct, aes_key)
211}
212
213fn pack_ciphertext(ct: &[u8]) -> Result<([u8; 32], [u8; 32]), String> {
218 if ct.len() != CIPHERTEXT_LEN {
219 return Err(format!(
220 "Ciphertext must be {CIPHERTEXT_LEN} bytes, got {}",
221 ct.len()
222 ));
223 }
224 let mut lo = [0u8; 32];
225 let mut hi = [0u8; 32];
226 lo.copy_from_slice(&ct[..32]);
227 hi[..12].copy_from_slice(&ct[32..44]);
228 Ok((lo, hi))
229}
230
231fn read_commit_nonce(cell: &truthlinked_runtime::cells::CellAccount) -> u128 {
232 let s = cell
233 .storage
234 .get(&pb_keys::COMMIT_NONCE)
235 .copied()
236 .unwrap_or([0u8; 32]);
237 let mut b = [0u8; 16];
238 b.copy_from_slice(&s[..16]);
239 u128::from_le_bytes(b)
240}
241
242fn read_u128_slot(cell: &truthlinked_runtime::cells::CellAccount, key: &[u8; 32]) -> u128 {
243 let s = cell.storage.get(key).copied().unwrap_or([0u8; 32]);
244 let mut b = [0u8; 16];
245 b.copy_from_slice(&s[..16]);
246 u128::from_le_bytes(b)
247}
248
249fn pack_u128(v: u128) -> [u8; 32] {
250 let mut s = [0u8; 32];
251 s[..16].copy_from_slice(&v.to_le_bytes());
252 s
253}
254
255fn pack_nonce(n: &[u8; 16]) -> [u8; 32] {
256 let mut s = [0u8; 32];
257 s[..16].copy_from_slice(n);
258 s
259}
260
261fn check_balance(v: u128, label: &str) -> Result<(), String> {
266 if v > MAX_PRIVATE_BALANCE {
267 return Err(format!("{label} ({v}) exceeds MAX_PRIVATE_BALANCE"));
268 }
269 Ok(())
270}
271
272fn check_amount(v: u128, label: &str) -> Result<(), String> {
273 if v == 0 {
274 return Err(format!("{label} must be > 0"));
275 }
276 if v > MAX_TRANSFER_AMOUNT {
277 return Err(format!("{label} ({v}) exceeds MAX_TRANSFER_AMOUNT"));
278 }
279 Ok(())
280}
281
282fn check_fee(v: u128) -> Result<(), String> {
283 if v == 0 {
284 return Err("Fee must be > 0".into());
285 }
286 if v > MAX_FEE_AMOUNT {
287 return Err(format!("Fee ({v}) exceeds MAX_FEE_AMOUNT"));
288 }
289 Ok(())
290}
291
292fn check_nonce(n: &[u8; 16]) -> Result<(), String> {
293 if n == &[0u8; 16] {
294 return Err("Commit nonce must be non-zero".into());
295 }
296 Ok(())
297}
298
299fn check_stake_gate(staking: &StakingState, owner_pubkey_bytes: &[u8]) -> Result<(), String> {
306 let stake = staking
307 .validators
308 .get(owner_pubkey_bytes)
309 .map(|v| v.active_stake)
310 .unwrap_or(0);
311 if stake < PRIVATE_BALANCE_MIN_STAKE {
312 return Err(format!(
313 "Insufficient stake: owner has {} staked, minimum required is {} (100,000 TRTH)",
314 stake, PRIVATE_BALANCE_MIN_STAKE
315 ));
316 }
317 Ok(())
318}
319
320fn owner_pubkey<'a>(state: &'a impl McpStateView, owner: &AccountId) -> Result<Vec<u8>, String> {
322 let acc = state
323 .accounts()
324 .get(owner)
325 .ok_or("Owner account not found")?;
326 if acc.pubkey_bytes.is_empty() {
327 return Err("Owner account has no registered pubkey".into());
328 }
329 Ok(acc.pubkey_bytes.clone())
330}
331
332struct CellGuard<'a> {
337 cell: &'a truthlinked_runtime::cells::CellAccount,
338 stored_owner: AccountId,
339 _stored_agent: AccountId,
340 _on_chain_commitment: [u8; 32],
341 old_nonce: u128,
342}
343
344fn load_and_guard<'a>(
345 state: &'a impl McpStateView,
346 cell_id: &AccountId,
347 agent_id: &AccountId,
348 sender: &AccountId,
349 old_commitment: &[u8; 32],
350) -> Result<CellGuard<'a>, String> {
351 let cell = state
352 .cells()
353 .cells
354 .get(cell_id)
355 .ok_or_else(|| format!("Private balance cell {} not found", hex::encode(cell_id)))?;
356
357 if cell
358 .storage
359 .get(&pb_keys::LOCKED)
360 .map(|b| b[0])
361 .unwrap_or(0)
362 != 1
363 {
364 return Err("Cell not initialized".into());
365 }
366
367 let stored_owner = cell
368 .storage
369 .get(&pb_keys::OWNER)
370 .copied()
371 .unwrap_or([0u8; 32]);
372 let stored_agent = cell
373 .storage
374 .get(&pb_keys::AGENT)
375 .copied()
376 .unwrap_or([0u8; 32]);
377
378 if sender != &stored_owner && sender != &stored_agent {
379 return Err("Unauthorized: sender is neither owner nor agent".into());
380 }
381 if agent_id != &stored_agent {
382 return Err("agent_id does not match cell's registered agent".into());
383 }
384
385 let on_chain_commitment = cell
386 .storage
387 .get(&pb_keys::COMMITMENT)
388 .copied()
389 .unwrap_or([0u8; 32]);
390 if old_commitment != &on_chain_commitment {
391 return Err("old_commitment mismatch: stale or replayed transaction".into());
392 }
393
394 let old_nonce = read_commit_nonce(cell);
395
396 Ok(CellGuard {
397 cell,
398 stored_owner,
399 _stored_agent: stored_agent,
400 _on_chain_commitment: on_chain_commitment,
401 old_nonce,
402 })
403}
404
405fn check_new_nonce(new_nonce_bytes: &[u8; 16], old_nonce: u128) -> Result<u128, String> {
406 check_nonce(new_nonce_bytes)?;
407 let mut b = [0u8; 16];
408 b.copy_from_slice(new_nonce_bytes);
409 let new_nonce = u128::from_le_bytes(b);
410 if new_nonce == old_nonce {
411 return Err("new_commit_nonce must differ from current nonce".into());
412 }
413 Ok(new_nonce)
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
421pub enum PrivateBalanceIntent {
422 InitPrivateBalance {
424 cell_id: AccountId,
425 agent_id: AccountId,
426 encrypted_balance: Vec<u8>,
427 commitment: [u8; 32],
428 commit_nonce: [u8; 16],
429 },
430
431 Deposit {
434 cell_id: AccountId,
435 agent_id: AccountId,
436 amount: u128,
437 new_encrypted_balance: Vec<u8>,
438 new_commitment: [u8; 32],
439 new_commit_nonce: [u8; 16],
440 old_commitment: [u8; 32],
441 },
442
443 Withdraw {
446 cell_id: AccountId,
447 agent_id: AccountId,
448 amount: u128,
449 recipient: AccountId,
450 new_encrypted_balance: Vec<u8>,
451 new_commitment: [u8; 32],
452 new_commit_nonce: [u8; 16],
453 old_commitment: [u8; 32],
454 },
455
456 ConfidentialTransfer {
463 sender_cell_id: AccountId,
464 sender_agent_id: AccountId,
465 recipient_cell_id: AccountId,
466 amount_commitment: [u8; 32],
468 stark_proof: Vec<u8>,
470 sender_new_encrypted: Vec<u8>,
472 sender_new_commitment: [u8; 32],
473 sender_new_commit_nonce: [u8; 16],
474 sender_old_commitment: [u8; 32],
475 recipient_new_encrypted: Vec<u8>,
477 recipient_new_commitment: [u8; 32],
478 recipient_new_commit_nonce: [u8; 16],
479 recipient_old_commitment: [u8; 32],
480 },
481
482 FeeDeduct {
484 cell_id: AccountId,
485 agent_id: AccountId,
486 fee_amount: u128,
487 fee_recipient: AccountId,
488 new_encrypted_balance: Vec<u8>,
489 new_commitment: [u8; 32],
490 new_commit_nonce: [u8; 16],
491 old_commitment: [u8; 32],
492 },
493}
494
495use crate::zk_transfer::{bytes_to_digest, verify_ct_proof, CtPublicInputs};
498
499pub fn circuit_init_private_balance(
504 state: &impl PrivateBalanceStateView,
505 sender: AccountId,
506 intent: &PrivateBalanceIntent,
507 timestamp: u64,
508) -> Result<StateDiff, String> {
509 let (cell_id, agent_id, encrypted_balance, commitment, commit_nonce) = match intent {
510 PrivateBalanceIntent::InitPrivateBalance {
511 cell_id,
512 agent_id,
513 encrypted_balance,
514 commitment,
515 commit_nonce,
516 } => (
517 cell_id,
518 agent_id,
519 encrypted_balance,
520 commitment,
521 commit_nonce,
522 ),
523 _ => return Err("Wrong intent".into()),
524 };
525
526 let pubkey = owner_pubkey(state, &sender)?;
528 check_stake_gate(state.staking(), &pubkey)?;
529
530 if sender == *agent_id {
532 return Err("Owner and agent must be different accounts".into());
533 }
534 if state.cells().cells.contains_key(cell_id) {
535 return Err(format!("Cell {} already exists", hex::encode(cell_id)));
536 }
537 let expected = pb_keys::cell_for_agent(agent_id);
538 if *cell_id != expected {
539 return Err(format!(
540 "cell_id mismatch: expected {}",
541 hex::encode(expected)
542 ));
543 }
544 if commitment == &[0u8; 32] {
545 return Err("Commitment must be non-zero".into());
546 }
547 check_nonce(commit_nonce)?;
548 if encrypted_balance.len() != CIPHERTEXT_LEN {
549 return Err(format!("encrypted_balance must be {CIPHERTEXT_LEN} bytes"));
550 }
551
552 let rent = gp::get_u128(gp::PARAM_STORAGE_RENT_LIFETIME_FEE);
553 let sender_acc = state.accounts().get(&sender).ok_or("Sender not found")?;
554 if sender_acc.balance < rent {
555 return Err("Insufficient balance for rent".into());
556 }
557
558 let (cipher_lo, cipher_hi) = pack_ciphertext(encrypted_balance)?;
559
560 let mut storage: HashMap<[u8; 32], [u8; 32]> = HashMap::new();
561 storage.insert(pb_keys::COMMITMENT, *commitment);
562 storage.insert(pb_keys::CIPHER_LO, cipher_lo);
563 storage.insert(pb_keys::CIPHER_HI, cipher_hi);
564 storage.insert(pb_keys::COMMIT_NONCE, pack_nonce(commit_nonce));
565 storage.insert(pb_keys::OWNER, sender);
566 storage.insert(pb_keys::AGENT, *agent_id);
567 storage.insert(pb_keys::TOTAL_DEPOSITED, [0u8; 32]);
568 storage.insert(pb_keys::TOTAL_WITHDRAWN, [0u8; 32]);
569 storage.insert(pb_keys::TOTAL_FEES, [0u8; 32]);
570 storage.insert(pb_keys::TOTAL_CT_OUT, [0u8; 32]);
571 let mut locked = [0u8; 32];
572 locked[0] = 1;
573 storage.insert(pb_keys::LOCKED, locked);
574 let mut sv = [0u8; 32];
575 sv[0] = 1;
576 storage.insert(pb_keys::STAKE_VERIFIED, sv);
577
578 let manifest_hash =
579 truthlinked_runtime::cells::CellAccount::compute_manifest_hash(&[], &[], &[], &[], &[]);
580
581 let cell = truthlinked_runtime::cells::CellAccount {
582 cell_id: *cell_id,
583 owner: truthlinked_core::pq_execution::system_authority_id(),
584 bytecode: vec![],
585 storage,
586 balance: 0,
587 rent_deposit: rent,
588 is_token: false,
589 token_config: None,
590 created_at: timestamp,
591 upgraded_at: None,
592 last_rent_paid_height: 0,
593 rent_grace_blocks: gp::get_u64(gp::PARAM_STORAGE_RENT_GRACE_PERIOD_BLOCKS),
594 pending_owner: None,
595 is_immutable: false,
596 declared_reads: vec![
597 pb_keys::COMMITMENT,
598 pb_keys::COMMIT_NONCE,
599 pb_keys::OWNER,
600 pb_keys::AGENT,
601 pb_keys::LOCKED,
602 pb_keys::STAKE_VERIFIED,
603 ],
604 declared_writes: vec![
605 pb_keys::COMMITMENT,
606 pb_keys::CIPHER_LO,
607 pb_keys::CIPHER_HI,
608 pb_keys::COMMIT_NONCE,
609 pb_keys::TOTAL_DEPOSITED,
610 pb_keys::TOTAL_WITHDRAWN,
611 pb_keys::TOTAL_FEES,
612 pb_keys::TOTAL_CT_OUT,
613 ],
614 commutative_keys: vec![],
615 storage_key_specs: vec![],
616 oracle_schema_ids: vec![],
617 governance_proposal: None,
618 manifest_version: 2,
619 manifest_hash,
620 };
621
622 let mut diff = StateDiff::default();
623 let mut sa = sender_acc.clone();
624 sa.balance = sa
625 .balance
626 .checked_sub(rent)
627 .ok_or("Balance underflow on rent")?;
628 diff.account_updates.insert(sender, sa);
629 diff.native_debits.push((sender, rent));
630 diff.cell_updates.push(CellUpdate::Deploy {
631 cell_id: *cell_id,
632 cell,
633 });
634 diff.cu_fee = gp::get_u64(gp::PARAM_GAS_DEPLOY_CELL) as u128;
635 Ok(diff)
636}
637
638pub fn circuit_deposit(
643 state: &impl PrivateBalanceStateView,
644 sender: AccountId,
645 intent: &PrivateBalanceIntent,
646 _ts: u64,
647) -> Result<StateDiff, String> {
648 let (cell_id, agent_id, amount, new_enc, new_comm, new_nonce, old_comm) = match intent {
649 PrivateBalanceIntent::Deposit {
650 cell_id,
651 agent_id,
652 amount,
653 new_encrypted_balance,
654 new_commitment,
655 new_commit_nonce,
656 old_commitment,
657 } => (
658 cell_id,
659 agent_id,
660 *amount,
661 new_encrypted_balance,
662 new_commitment,
663 new_commit_nonce,
664 old_commitment,
665 ),
666 _ => return Err("Wrong intent".into()),
667 };
668
669 let g = load_and_guard(state, cell_id, agent_id, &sender, old_comm)?;
670
671 let pubkey = owner_pubkey(state, &g.stored_owner)?;
673 check_stake_gate(state.staking(), &pubkey)?;
674
675 if sender != g.stored_owner {
677 return Err("Only the owner can deposit into a private balance cell".into());
678 }
679
680 check_amount(amount, "deposit amount")?;
681
682 let sender_acc = state.accounts().get(&sender).ok_or("Sender not found")?;
683 if sender_acc.balance < amount {
684 return Err("Insufficient on-chain balance for deposit".into());
685 }
686
687 check_new_nonce(new_nonce, g.old_nonce)?;
688 if new_comm == &[0u8; 32] {
689 return Err("new_commitment must be non-zero".into());
690 }
691 if new_enc.len() != CIPHERTEXT_LEN {
692 return Err(format!(
693 "new_encrypted_balance must be {CIPHERTEXT_LEN} bytes"
694 ));
695 }
696
697 let old_deposited = read_u128_slot(g.cell, &pb_keys::TOTAL_DEPOSITED);
698 let new_deposited = old_deposited
699 .checked_add(amount)
700 .ok_or("TOTAL_DEPOSITED overflow")?;
701 check_balance(new_deposited, "total_deposited")?;
702
703 let (cipher_lo, cipher_hi) = pack_ciphertext(new_enc)?;
704
705 let mut diff = StateDiff::default();
706
707 let mut sa = sender_acc.clone();
709 sa.balance = sa
710 .balance
711 .checked_sub(amount)
712 .ok_or("Sender balance underflow")?;
713 diff.account_updates.insert(sender, sa);
714 diff.native_debits.push((sender, amount));
715
716 diff.cell_updates.push(CellUpdate::StorageChange {
717 cell_id: *cell_id,
718 storage_diff: {
719 let mut m = HashMap::new();
720 m.insert(pb_keys::COMMITMENT, Some(*new_comm));
721 m.insert(pb_keys::CIPHER_LO, Some(cipher_lo));
722 m.insert(pb_keys::CIPHER_HI, Some(cipher_hi));
723 m.insert(pb_keys::COMMIT_NONCE, Some(pack_nonce(new_nonce)));
724 m.insert(pb_keys::TOTAL_DEPOSITED, Some(pack_u128(new_deposited)));
725 m
726 },
727 });
728
729 diff.cu_fee = gp::get_u64(gp::PARAM_GAS_TRANSFER) as u128;
730 Ok(diff)
731}
732
733pub fn circuit_withdraw(
738 state: &impl PrivateBalanceStateView,
739 sender: AccountId,
740 intent: &PrivateBalanceIntent,
741 _ts: u64,
742) -> Result<StateDiff, String> {
743 let (cell_id, agent_id, amount, recipient, new_enc, new_comm, new_nonce, old_comm) =
744 match intent {
745 PrivateBalanceIntent::Withdraw {
746 cell_id,
747 agent_id,
748 amount,
749 recipient,
750 new_encrypted_balance,
751 new_commitment,
752 new_commit_nonce,
753 old_commitment,
754 } => (
755 cell_id,
756 agent_id,
757 *amount,
758 recipient,
759 new_encrypted_balance,
760 new_commitment,
761 new_commit_nonce,
762 old_commitment,
763 ),
764 _ => return Err("Wrong intent".into()),
765 };
766
767 let g = load_and_guard(state, cell_id, agent_id, &sender, old_comm)?;
768
769 let pubkey = owner_pubkey(state, &g.stored_owner)?;
771 check_stake_gate(state.staking(), &pubkey)?;
772
773 check_amount(amount, "withdrawal amount")?;
774 check_new_nonce(new_nonce, g.old_nonce)?;
775 if new_comm == &[0u8; 32] {
776 return Err("new_commitment must be non-zero".into());
777 }
778 if new_enc.len() != CIPHERTEXT_LEN {
779 return Err(format!(
780 "new_encrypted_balance must be {CIPHERTEXT_LEN} bytes"
781 ));
782 }
783
784 let recipient_acc = state
785 .accounts()
786 .get(recipient)
787 .ok_or("Recipient not found")?;
788 let new_recipient_balance = recipient_acc
789 .balance
790 .checked_add(amount)
791 .ok_or("Recipient balance overflow")?;
792 check_balance(new_recipient_balance, "recipient new balance")?;
793
794 let old_withdrawn = read_u128_slot(g.cell, &pb_keys::TOTAL_WITHDRAWN);
795 let new_withdrawn = old_withdrawn
796 .checked_add(amount)
797 .ok_or("TOTAL_WITHDRAWN overflow")?;
798 check_balance(new_withdrawn, "total_withdrawn")?;
799
800 let (cipher_lo, cipher_hi) = pack_ciphertext(new_enc)?;
801
802 let mut diff = StateDiff::default();
803
804 let mut ra = recipient_acc.clone();
806 ra.balance = new_recipient_balance;
807 diff.account_updates.insert(*recipient, ra);
808 diff.native_transfers.push((*recipient, amount));
809
810 diff.cell_updates.push(CellUpdate::StorageChange {
811 cell_id: *cell_id,
812 storage_diff: {
813 let mut m = HashMap::new();
814 m.insert(pb_keys::COMMITMENT, Some(*new_comm));
815 m.insert(pb_keys::CIPHER_LO, Some(cipher_lo));
816 m.insert(pb_keys::CIPHER_HI, Some(cipher_hi));
817 m.insert(pb_keys::COMMIT_NONCE, Some(pack_nonce(new_nonce)));
818 m.insert(pb_keys::TOTAL_WITHDRAWN, Some(pack_u128(new_withdrawn)));
819 m
820 },
821 });
822
823 diff.cu_fee = gp::get_u64(gp::PARAM_GAS_TRANSFER) as u128;
824 Ok(diff)
825}
826
827pub fn circuit_confidential_transfer(
832 state: &impl PrivateBalanceStateView,
833 sender: AccountId,
834 intent: &PrivateBalanceIntent,
835 _ts: u64,
836) -> Result<StateDiff, String> {
837 let (
838 sender_cell_id,
839 sender_agent_id,
840 recipient_cell_id,
841 amount_commitment,
842 stark_proof,
843 s_new_enc,
844 s_new_comm,
845 s_new_nonce,
846 s_old_comm,
847 r_new_enc,
848 r_new_comm,
849 r_new_nonce,
850 r_old_comm,
851 ) = match intent {
852 PrivateBalanceIntent::ConfidentialTransfer {
853 sender_cell_id,
854 sender_agent_id,
855 recipient_cell_id,
856 amount_commitment,
857 stark_proof,
858 sender_new_encrypted,
859 sender_new_commitment,
860 sender_new_commit_nonce,
861 sender_old_commitment,
862 recipient_new_encrypted,
863 recipient_new_commitment,
864 recipient_new_commit_nonce,
865 recipient_old_commitment,
866 } => (
867 sender_cell_id,
868 sender_agent_id,
869 recipient_cell_id,
870 amount_commitment,
871 stark_proof,
872 sender_new_encrypted,
873 sender_new_commitment,
874 sender_new_commit_nonce,
875 sender_old_commitment,
876 recipient_new_encrypted,
877 recipient_new_commitment,
878 recipient_new_commit_nonce,
879 recipient_old_commitment,
880 ),
881 _ => return Err("Wrong intent".into()),
882 };
883
884 let sg = load_and_guard(state, sender_cell_id, sender_agent_id, &sender, s_old_comm)?;
886
887 let pubkey = owner_pubkey(state, &sg.stored_owner)?;
889 check_stake_gate(state.staking(), &pubkey)?;
890
891 let r_cell = state.cells().cells.get(recipient_cell_id).ok_or_else(|| {
893 format!(
894 "Recipient cell {} not found",
895 hex::encode(recipient_cell_id)
896 )
897 })?;
898 if r_cell
899 .storage
900 .get(&pb_keys::LOCKED)
901 .map(|b| b[0])
902 .unwrap_or(0)
903 != 1
904 {
905 return Err("Recipient cell not initialized".into());
906 }
907 let r_on_chain_comm = r_cell
908 .storage
909 .get(&pb_keys::COMMITMENT)
910 .copied()
911 .unwrap_or([0u8; 32]);
912 if r_old_comm != &r_on_chain_comm {
913 return Err("recipient_old_commitment mismatch".into());
914 }
915 let r_old_nonce = read_commit_nonce(r_cell);
916
917 if sender_cell_id == recipient_cell_id {
919 return Err("Sender and recipient cells must be different".into());
920 }
921
922 check_new_nonce(s_new_nonce, sg.old_nonce)?;
924 check_new_nonce(r_new_nonce, r_old_nonce)?;
925
926 if s_new_comm == &[0u8; 32] {
928 return Err("sender new_commitment must be non-zero".into());
929 }
930 if r_new_comm == &[0u8; 32] {
931 return Err("recipient new_commitment must be non-zero".into());
932 }
933 if amount_commitment == &[0u8; 32] {
934 return Err("amount_commitment must be non-zero".into());
935 }
936
937 if s_new_enc.len() != CIPHERTEXT_LEN {
939 return Err(format!(
940 "sender new_encrypted must be {CIPHERTEXT_LEN} bytes"
941 ));
942 }
943 if r_new_enc.len() != CIPHERTEXT_LEN {
944 return Err(format!(
945 "recipient new_encrypted must be {CIPHERTEXT_LEN} bytes"
946 ));
947 }
948
949 const MAX_PROOF_BYTES: usize = 512 * 1024; if stark_proof.is_empty() {
952 return Err("STARK proof is empty".into());
953 }
954 if stark_proof.len() > MAX_PROOF_BYTES {
955 return Err(format!(
956 "STARK proof too large: {} bytes (max {MAX_PROOF_BYTES})",
957 stark_proof.len()
958 ));
959 }
960
961 let pub_inputs = CtPublicInputs {
963 s_old: bytes_to_digest(s_old_comm),
964 s_new: bytes_to_digest(s_new_comm),
965 r_old: bytes_to_digest(r_old_comm),
966 r_new: bytes_to_digest(r_new_comm),
967 amt: bytes_to_digest(amount_commitment),
968 };
969 verify_ct_proof(stark_proof, &pub_inputs)?;
970
971 let old_ct_out = read_u128_slot(sg.cell, &pb_keys::TOTAL_CT_OUT);
975 let new_ct_out = old_ct_out.checked_add(1).ok_or("TOTAL_CT_OUT overflow")?;
976
977 let (s_cipher_lo, s_cipher_hi) = pack_ciphertext(s_new_enc)?;
978 let (r_cipher_lo, r_cipher_hi) = pack_ciphertext(r_new_enc)?;
979
980 let mut diff = StateDiff::default();
981
982 diff.cell_updates.push(CellUpdate::StorageChange {
984 cell_id: *sender_cell_id,
985 storage_diff: {
986 let mut m = HashMap::new();
987 m.insert(pb_keys::COMMITMENT, Some(*s_new_comm));
988 m.insert(pb_keys::CIPHER_LO, Some(s_cipher_lo));
989 m.insert(pb_keys::CIPHER_HI, Some(s_cipher_hi));
990 m.insert(pb_keys::COMMIT_NONCE, Some(pack_nonce(s_new_nonce)));
991 m.insert(pb_keys::TOTAL_CT_OUT, Some(pack_u128(new_ct_out)));
992 m
993 },
994 });
995
996 diff.cell_updates.push(CellUpdate::StorageChange {
998 cell_id: *recipient_cell_id,
999 storage_diff: {
1000 let mut m = HashMap::new();
1001 m.insert(pb_keys::COMMITMENT, Some(*r_new_comm));
1002 m.insert(pb_keys::CIPHER_LO, Some(r_cipher_lo));
1003 m.insert(pb_keys::CIPHER_HI, Some(r_cipher_hi));
1004 m.insert(pb_keys::COMMIT_NONCE, Some(pack_nonce(r_new_nonce)));
1005 m
1006 },
1007 });
1008
1009 diff.cu_fee = gp::get_u64(gp::PARAM_GAS_TRANSFER) as u128 * 3;
1010 Ok(diff)
1011}
1012
1013pub fn circuit_fee_deduct(
1018 state: &impl PrivateBalanceStateView,
1019 sender: AccountId,
1020 intent: &PrivateBalanceIntent,
1021 _ts: u64,
1022) -> Result<StateDiff, String> {
1023 let (cell_id, agent_id, fee_amount, fee_recipient, new_enc, new_comm, new_nonce, old_comm) =
1024 match intent {
1025 PrivateBalanceIntent::FeeDeduct {
1026 cell_id,
1027 agent_id,
1028 fee_amount,
1029 fee_recipient,
1030 new_encrypted_balance,
1031 new_commitment,
1032 new_commit_nonce,
1033 old_commitment,
1034 } => (
1035 cell_id,
1036 agent_id,
1037 *fee_amount,
1038 fee_recipient,
1039 new_encrypted_balance,
1040 new_commitment,
1041 new_commit_nonce,
1042 old_commitment,
1043 ),
1044 _ => return Err("Wrong intent".into()),
1045 };
1046
1047 let fee_authority = gp::get_bytes32(gp::PARAM_FEE_AUTHORITY);
1049 if sender != fee_authority {
1050 return Err("Only the fee authority can deduct private fees".into());
1051 }
1052
1053 let g = load_and_guard(state, cell_id, agent_id, &sender, old_comm)?;
1054
1055 check_fee(fee_amount)?;
1056 let gov_max = gp::get_u128(gp::PARAM_MAX_PRIVATE_FEE);
1057 if fee_amount > gov_max {
1058 return Err(format!("Fee {fee_amount} exceeds governance max {gov_max}"));
1059 }
1060
1061 check_new_nonce(new_nonce, g.old_nonce)?;
1062 if new_comm == &[0u8; 32] {
1063 return Err("new_commitment must be non-zero".into());
1064 }
1065 if new_enc.len() != CIPHERTEXT_LEN {
1066 return Err(format!(
1067 "new_encrypted_balance must be {CIPHERTEXT_LEN} bytes"
1068 ));
1069 }
1070
1071 let fee_rec_acc = state
1072 .accounts()
1073 .get(fee_recipient)
1074 .ok_or("Fee recipient not found")?;
1075 let new_fee_rec_bal = fee_rec_acc
1076 .balance
1077 .checked_add(fee_amount)
1078 .ok_or("Fee recipient overflow")?;
1079 check_balance(new_fee_rec_bal, "fee recipient balance")?;
1080
1081 let old_fees = read_u128_slot(g.cell, &pb_keys::TOTAL_FEES);
1082 let new_fees = old_fees
1083 .checked_add(fee_amount)
1084 .ok_or("TOTAL_FEES overflow")?;
1085 check_balance(new_fees, "total_fees")?;
1086
1087 let (cipher_lo, cipher_hi) = pack_ciphertext(new_enc)?;
1088
1089 let mut diff = StateDiff::default();
1090
1091 let mut fra = fee_rec_acc.clone();
1092 fra.balance = new_fee_rec_bal;
1093 diff.account_updates.insert(*fee_recipient, fra);
1094
1095 diff.cell_updates.push(CellUpdate::StorageChange {
1096 cell_id: *cell_id,
1097 storage_diff: {
1098 let mut m = HashMap::new();
1099 m.insert(pb_keys::COMMITMENT, Some(*new_comm));
1100 m.insert(pb_keys::CIPHER_LO, Some(cipher_lo));
1101 m.insert(pb_keys::CIPHER_HI, Some(cipher_hi));
1102 m.insert(pb_keys::COMMIT_NONCE, Some(pack_nonce(new_nonce)));
1103 m.insert(pb_keys::TOTAL_FEES, Some(pack_u128(new_fees)));
1104 m
1105 },
1106 });
1107
1108 diff.cu_fee = gp::get_u64(gp::PARAM_GAS_TRANSFER) as u128;
1109 Ok(diff)
1110}
1111
1112pub fn conflict_domain(
1117 intent: &PrivateBalanceIntent,
1118) -> (
1119 Vec<truthlinked_runtime::compiler_aware::StorageKey>,
1120 Vec<truthlinked_runtime::compiler_aware::StorageKey>,
1121) {
1122 use truthlinked_runtime::compiler_aware::StorageKey;
1123
1124 let cell_reads = |cid: &AccountId| {
1125 vec![
1126 StorageKey::CellStorage(*cid, pb_keys::COMMITMENT),
1127 StorageKey::CellStorage(*cid, pb_keys::COMMIT_NONCE),
1128 StorageKey::CellStorage(*cid, pb_keys::LOCKED),
1129 StorageKey::CellStorage(*cid, pb_keys::OWNER),
1130 StorageKey::CellStorage(*cid, pb_keys::AGENT),
1131 ]
1132 };
1133 let cell_writes = |cid: &AccountId| {
1134 vec![
1135 StorageKey::CellStorage(*cid, pb_keys::COMMITMENT),
1136 StorageKey::CellStorage(*cid, pb_keys::CIPHER_LO),
1137 StorageKey::CellStorage(*cid, pb_keys::CIPHER_HI),
1138 StorageKey::CellStorage(*cid, pb_keys::COMMIT_NONCE),
1139 ]
1140 };
1141
1142 match intent {
1143 PrivateBalanceIntent::InitPrivateBalance { cell_id, .. } => (vec![], cell_writes(cell_id)),
1144
1145 PrivateBalanceIntent::Deposit { cell_id, .. } => {
1146 let mut w = cell_writes(cell_id);
1147 w.push(StorageKey::CellStorage(*cell_id, pb_keys::TOTAL_DEPOSITED));
1148 (cell_reads(cell_id), w)
1149 }
1150
1151 PrivateBalanceIntent::Withdraw { cell_id, .. } => {
1152 let mut w = cell_writes(cell_id);
1153 w.push(StorageKey::CellStorage(*cell_id, pb_keys::TOTAL_WITHDRAWN));
1154 (cell_reads(cell_id), w)
1155 }
1156
1157 PrivateBalanceIntent::ConfidentialTransfer {
1158 sender_cell_id,
1159 recipient_cell_id,
1160 ..
1161 } => {
1162 let mut r = cell_reads(sender_cell_id);
1163 r.extend(cell_reads(recipient_cell_id));
1164 let mut w = cell_writes(sender_cell_id);
1165 w.extend(cell_writes(recipient_cell_id));
1166 w.push(StorageKey::CellStorage(
1167 *sender_cell_id,
1168 pb_keys::TOTAL_CT_OUT,
1169 ));
1170 (r, w)
1171 }
1172
1173 PrivateBalanceIntent::FeeDeduct { cell_id, .. } => {
1174 let mut w = cell_writes(cell_id);
1175 w.push(StorageKey::CellStorage(*cell_id, pb_keys::TOTAL_FEES));
1176 (cell_reads(cell_id), w)
1177 }
1178 }
1179}
1180
1181#[cfg(test)]
1186mod tests {
1187 use super::*;
1188 use im::HashMap as ImHashMap;
1189 use truthlinked_runtime::cells::CellState;
1190 use truthlinked_runtime::types::AccountRecord;
1191 use truthlinked_staking::{StakingState, ValidatorStake};
1192
1193 struct TestState {
1194 cells: CellState,
1195 accounts: ImHashMap<AccountId, AccountRecord>,
1196 params: ImHashMap<[u8; 32], [u8; 32]>,
1197 staking: StakingState,
1198 }
1199
1200 impl McpStateView for TestState {
1201 fn cells(&self) -> &CellState {
1202 &self.cells
1203 }
1204 fn accounts(&self) -> &ImHashMap<AccountId, AccountRecord> {
1205 &self.accounts
1206 }
1207 }
1208
1209 impl PrivateBalanceStateView for TestState {
1210 fn staking(&self) -> &StakingState {
1211 &self.staking
1212 }
1213 }
1214
1215 impl truthlinked_governance::params::ParamState for TestState {
1216 fn params(&self) -> &ImHashMap<[u8; 32], [u8; 32]> {
1217 &self.params
1218 }
1219 fn params_mut(&mut self) -> &mut ImHashMap<[u8; 32], [u8; 32]> {
1220 &mut self.params
1221 }
1222 }
1223
1224 fn make_state() -> TestState {
1225 let mut s = TestState {
1226 cells: CellState::new(),
1227 accounts: ImHashMap::new(),
1228 params: ImHashMap::new(),
1229 staking: StakingState::new(),
1230 };
1231 truthlinked_governance::params::insert_genesis_params(&mut s);
1232 truthlinked_governance::params::rehydrate_from_state(&s);
1233 s
1234 }
1235
1236 fn add_account(s: &mut TestState, id: AccountId, balance: u128) {
1237 s.accounts.insert(
1238 id,
1239 AccountRecord {
1240 pubkey_bytes: id.to_vec(), balance,
1242 compute_escrow_trth: 0,
1243 nonce: 0,
1244 nfts: vec![],
1245 },
1246 );
1247 }
1248
1249 fn stake_owner(s: &mut TestState, owner: AccountId) {
1250 s.staking.validators.insert(
1252 owner.to_vec(),
1253 ValidatorStake {
1254 active_stake: PRIVATE_BALANCE_MIN_STAKE,
1255 unbonding: vec![],
1256 jailed_until: None,
1257 },
1258 );
1259 }
1260
1261 fn dummy_ct() -> Vec<u8> {
1262 vec![0xABu8; CIPHERTEXT_LEN]
1263 }
1264 fn dummy_comm() -> [u8; 32] {
1265 [0x01u8; 32]
1266 }
1267 fn dummy_nonce() -> [u8; 16] {
1268 [0x02u8; 16]
1269 }
1270
1271 fn do_init(s: &mut TestState, owner: AccountId, agent: AccountId) -> AccountId {
1272 let cell_id = pb_keys::cell_for_agent(&agent);
1273 let rent = gp::get_u128(gp::PARAM_STORAGE_RENT_LIFETIME_FEE);
1274 add_account(s, owner, rent * 10 + 1_000_000_000_000);
1275 stake_owner(s, owner);
1276
1277 let intent = PrivateBalanceIntent::InitPrivateBalance {
1278 cell_id,
1279 agent_id: agent,
1280 encrypted_balance: dummy_ct(),
1281 commitment: dummy_comm(),
1282 commit_nonce: dummy_nonce(),
1283 };
1284 let diff = circuit_init_private_balance(s, owner, &intent, 0).unwrap();
1285 for u in diff.cell_updates {
1286 if let CellUpdate::Deploy { cell_id: cid, cell } = u {
1287 s.cells.cells.insert(cid, cell);
1288 }
1289 }
1290 for (id, acc) in diff.account_updates {
1291 s.accounts.insert(id, acc);
1292 }
1293 cell_id
1294 }
1295
1296 #[test]
1299 fn init_rejects_unstaked_owner() {
1300 let mut s = make_state();
1301 let owner = [1u8; 32];
1302 let agent = [2u8; 32];
1303 let rent = gp::get_u128(gp::PARAM_STORAGE_RENT_LIFETIME_FEE);
1304 add_account(&mut s, owner, rent * 10);
1305 let cell_id = pb_keys::cell_for_agent(&agent);
1308 let intent = PrivateBalanceIntent::InitPrivateBalance {
1309 cell_id,
1310 agent_id: agent,
1311 encrypted_balance: dummy_ct(),
1312 commitment: dummy_comm(),
1313 commit_nonce: dummy_nonce(),
1314 };
1315 let err = circuit_init_private_balance(&s, owner, &intent, 0).unwrap_err();
1316 assert!(err.contains("Insufficient stake"), "got: {err}");
1317 }
1318
1319 #[test]
1320 fn deposit_rejects_unstaked_owner() {
1321 let mut s = make_state();
1322 let owner = [1u8; 32];
1323 let agent = [2u8; 32];
1324 let cell_id = do_init(&mut s, owner, agent);
1325
1326 s.staking.validators.remove(&owner.to_vec());
1328
1329 let intent = PrivateBalanceIntent::Deposit {
1330 cell_id,
1331 agent_id: agent,
1332 amount: 1000,
1333 new_encrypted_balance: dummy_ct(),
1334 new_commitment: [0x03u8; 32],
1335 new_commit_nonce: [0x05u8; 16],
1336 old_commitment: dummy_comm(),
1337 };
1338 let err = circuit_deposit(&s, owner, &intent, 0).unwrap_err();
1339 assert!(err.contains("Insufficient stake"), "got: {err}");
1340 }
1341
1342 #[test]
1345 fn init_rejects_self_register() {
1346 let mut s = make_state();
1347 let owner = [1u8; 32];
1348 let rent = gp::get_u128(gp::PARAM_STORAGE_RENT_LIFETIME_FEE);
1349 add_account(&mut s, owner, rent * 10);
1350 stake_owner(&mut s, owner);
1351
1352 let cell_id = pb_keys::cell_for_agent(&owner);
1353 let intent = PrivateBalanceIntent::InitPrivateBalance {
1354 cell_id,
1355 agent_id: owner,
1356 encrypted_balance: dummy_ct(),
1357 commitment: dummy_comm(),
1358 commit_nonce: dummy_nonce(),
1359 };
1360 let err = circuit_init_private_balance(&s, owner, &intent, 0).unwrap_err();
1361 assert!(err.contains("different accounts"), "got: {err}");
1362 }
1363
1364 #[test]
1365 fn init_rejects_wrong_cell_id() {
1366 let mut s = make_state();
1367 let owner = [1u8; 32];
1368 let agent = [2u8; 32];
1369 let rent = gp::get_u128(gp::PARAM_STORAGE_RENT_LIFETIME_FEE);
1370 add_account(&mut s, owner, rent * 10);
1371 stake_owner(&mut s, owner);
1372
1373 let intent = PrivateBalanceIntent::InitPrivateBalance {
1374 cell_id: [0xFFu8; 32],
1375 agent_id: agent,
1376 encrypted_balance: dummy_ct(),
1377 commitment: dummy_comm(),
1378 commit_nonce: dummy_nonce(),
1379 };
1380 let err = circuit_init_private_balance(&s, owner, &intent, 0).unwrap_err();
1381 assert!(err.contains("cell_id mismatch"), "got: {err}");
1382 }
1383
1384 #[test]
1385 fn init_succeeds() {
1386 let mut s = make_state();
1387 let owner = [1u8; 32];
1388 let agent = [2u8; 32];
1389 let cell_id = do_init(&mut s, owner, agent);
1390 assert!(s.cells.cells.contains_key(&cell_id));
1391 let cell = &s.cells.cells[&cell_id];
1392 assert_eq!(cell.storage[&pb_keys::LOCKED][0], 1);
1393 assert_eq!(cell.storage[&pb_keys::STAKE_VERIFIED][0], 1);
1394 }
1395
1396 #[test]
1399 fn deposit_rejects_zero_amount() {
1400 let mut s = make_state();
1401 let owner = [1u8; 32];
1402 let agent = [2u8; 32];
1403 let cell_id = do_init(&mut s, owner, agent);
1404 stake_owner(&mut s, owner);
1405
1406 let intent = PrivateBalanceIntent::Deposit {
1407 cell_id,
1408 agent_id: agent,
1409 amount: 0,
1410 new_encrypted_balance: dummy_ct(),
1411 new_commitment: [0x03u8; 32],
1412 new_commit_nonce: [0x05u8; 16],
1413 old_commitment: dummy_comm(),
1414 };
1415 let err = circuit_deposit(&s, owner, &intent, 0).unwrap_err();
1416 assert!(err.contains("must be > 0"), "got: {err}");
1417 }
1418
1419 #[test]
1420 fn deposit_rejects_stale_commitment() {
1421 let mut s = make_state();
1422 let owner = [1u8; 32];
1423 let agent = [2u8; 32];
1424 let cell_id = do_init(&mut s, owner, agent);
1425 stake_owner(&mut s, owner);
1426
1427 let intent = PrivateBalanceIntent::Deposit {
1428 cell_id,
1429 agent_id: agent,
1430 amount: 1000,
1431 new_encrypted_balance: dummy_ct(),
1432 new_commitment: [0x03u8; 32],
1433 new_commit_nonce: [0x05u8; 16],
1434 old_commitment: [0xFFu8; 32], };
1436 let err = circuit_deposit(&s, owner, &intent, 0).unwrap_err();
1437 assert!(err.contains("old_commitment mismatch"), "got: {err}");
1438 }
1439
1440 #[test]
1441 fn deposit_rejects_insufficient_balance() {
1442 let mut s = make_state();
1443 let owner = [1u8; 32];
1444 let agent = [2u8; 32];
1445 let cell_id = do_init(&mut s, owner, agent);
1446 stake_owner(&mut s, owner);
1447 s.accounts.get_mut(&owner).unwrap().balance = 0;
1449
1450 let intent = PrivateBalanceIntent::Deposit {
1451 cell_id,
1452 agent_id: agent,
1453 amount: 1_000_000,
1454 new_encrypted_balance: dummy_ct(),
1455 new_commitment: [0x03u8; 32],
1456 new_commit_nonce: [0x05u8; 16],
1457 old_commitment: dummy_comm(),
1458 };
1459 let err = circuit_deposit(&s, owner, &intent, 0).unwrap_err();
1460 assert!(err.contains("Insufficient"), "got: {err}");
1461 }
1462
1463 #[test]
1464 fn deposit_succeeds_and_debits_sender() {
1465 let mut s = make_state();
1466 let owner = [1u8; 32];
1467 let agent = [2u8; 32];
1468 let cell_id = do_init(&mut s, owner, agent);
1469 stake_owner(&mut s, owner);
1470 let before = s.accounts[&owner].balance;
1471
1472 let amount = 500_000_000u128;
1473 let intent = PrivateBalanceIntent::Deposit {
1474 cell_id,
1475 agent_id: agent,
1476 amount,
1477 new_encrypted_balance: dummy_ct(),
1478 new_commitment: [0x03u8; 32],
1479 new_commit_nonce: [0x05u8; 16],
1480 old_commitment: dummy_comm(),
1481 };
1482 let diff = circuit_deposit(&s, owner, &intent, 0).unwrap();
1483 let new_bal = diff.account_updates[&owner].balance;
1484 assert_eq!(new_bal, before - amount);
1485 }
1486
1487 #[test]
1490 fn withdraw_rejects_same_nonce() {
1491 let mut s = make_state();
1492 let owner = [1u8; 32];
1493 let agent = [2u8; 32];
1494 let cell_id = do_init(&mut s, owner, agent);
1495 stake_owner(&mut s, owner);
1496 let recipient = [5u8; 32];
1497 add_account(&mut s, recipient, 0);
1498
1499 let intent = PrivateBalanceIntent::Withdraw {
1500 cell_id,
1501 agent_id: agent,
1502 amount: 100,
1503 recipient,
1504 new_encrypted_balance: dummy_ct(),
1505 new_commitment: [0x03u8; 32],
1506 new_commit_nonce: dummy_nonce(), old_commitment: dummy_comm(),
1508 };
1509 let err = circuit_withdraw(&s, owner, &intent, 0).unwrap_err();
1510 assert!(err.contains("nonce"), "got: {err}");
1511 }
1512
1513 #[test]
1514 fn withdraw_credits_recipient() {
1515 let mut s = make_state();
1516 let owner = [1u8; 32];
1517 let agent = [2u8; 32];
1518 let cell_id = do_init(&mut s, owner, agent);
1519 stake_owner(&mut s, owner);
1520 let recipient = [5u8; 32];
1521 add_account(&mut s, recipient, 0);
1522
1523 let amount = 999u128;
1524 let intent = PrivateBalanceIntent::Withdraw {
1525 cell_id,
1526 agent_id: agent,
1527 amount,
1528 recipient,
1529 new_encrypted_balance: dummy_ct(),
1530 new_commitment: [0x03u8; 32],
1531 new_commit_nonce: [0x05u8; 16],
1532 old_commitment: dummy_comm(),
1533 };
1534 let diff = circuit_withdraw(&s, owner, &intent, 0).unwrap();
1535 assert_eq!(diff.account_updates[&recipient].balance, amount);
1536 }
1537
1538 #[test]
1541 fn encrypt_decrypt_roundtrip() {
1542 let key = derive_aes_key(b"test_seed_32bytes_exactly_padded!");
1543 let balance = 123_456_789_000u128;
1544 let nonce = [0x42u8; AES_NONCE_LEN];
1545 let ct = encrypt_balance(balance, &key, &nonce).unwrap();
1546 assert_eq!(ct.len(), CIPHERTEXT_LEN);
1547 assert_eq!(decrypt_balance(&ct, &key).unwrap(), balance);
1548 }
1549
1550 #[test]
1551 fn wrong_key_fails_decryption() {
1552 let key = derive_aes_key(b"correct_seed_32bytes_exactly____");
1553 let bad = derive_aes_key(b"wrong___seed_32bytes_exactly____");
1554 let ct = encrypt_balance(42, &key, &[0x11u8; AES_NONCE_LEN]).unwrap();
1555 assert!(decrypt_balance(&ct, &bad).is_err());
1556 }
1557
1558 #[test]
1561 fn commitment_binds_ciphertext() {
1562 let ct1 = vec![0xAAu8; CIPHERTEXT_LEN];
1563 let ct2 = vec![0xBBu8; CIPHERTEXT_LEN];
1564 let c1 = compute_commitment(100, 1, &ct1);
1565 let c2 = compute_commitment(100, 1, &ct2);
1566 assert_ne!(
1567 c1, c2,
1568 "Different ciphertexts must produce different commitments"
1569 );
1570 }
1571
1572 #[test]
1573 fn commitment_verify_ok() {
1574 let ct = dummy_ct();
1575 let c = compute_commitment(500, 7, &ct);
1576 verify_commitment(&c, 500, 7, &ct).unwrap();
1577 }
1578
1579 #[test]
1580 fn commitment_verify_wrong_balance_fails() {
1581 let ct = dummy_ct();
1582 let c = compute_commitment(500, 7, &ct);
1583 assert!(verify_commitment(&c, 501, 7, &ct).is_err());
1584 }
1585
1586 #[test]
1589 fn range_check_balance_overflow() {
1590 assert!(check_balance(MAX_PRIVATE_BALANCE + 1, "x").is_err());
1591 }
1592
1593 #[test]
1594 fn range_check_amount_zero() {
1595 assert!(check_amount(0, "x").is_err());
1596 }
1597
1598 #[test]
1599 fn range_check_amount_overflow() {
1600 assert!(check_amount(MAX_TRANSFER_AMOUNT + 1, "x").is_err());
1601 }
1602
1603 #[test]
1604 fn range_check_fee_overflow() {
1605 assert!(check_fee(MAX_FEE_AMOUNT + 1).is_err());
1606 }
1607}