Skip to main content

truthlinked_mcp/
private_balance.rs

1//! Agent Private Balance v2 - Full Confidential System
2//!
3//! ## Privacy model
4//!
5//! On-chain storage per agent cell:
6//!   COMMITMENT   = blake3(balance_le16 || nonce_le16 || blake3(ciphertext)[..16])
7//!                  - binds balance, nonce, AND ciphertext together
8//!   CIPHER_LO/HI = AES-256-GCM(balance, owner_aes_key, random_nonce)
9//!   TOTAL_DEPOSITED, TOTAL_WITHDRAWN, TOTAL_FEES - monotonic u128 counters
10//!   STAKE_VERIFIED - u8 flag set at init, re-checked every circuit
11//!
12//! Deposit:  public TRTH → private balance. Amount revealed (it's a public tx).
13//!           Balance before/after hidden. Commitment + ciphertext updated.
14//!
15//! Withdraw: private balance → public TRTH. Amount revealed.
16//!           Balance before/after hidden.
17//!
18//! Confidential Transfer: amount AND balances hidden.
19//!           A Winterfell STARK proof is submitted with the tx.
20//!           The proof attests: old_balance - amount = new_balance,
21//!           all three in [0, MAX_PRIVATE_BALANCE], using a public
22//!           Pedersen-style commitment to amount (so verifier checks
23//!           the commitment without learning the value).
24//!
25//! ## Staking gate
26//!
27//! Owner must have ≥ PRIVATE_BALANCE_MIN_STAKE TRTH staked at init time.
28//! The stake requirement is re-checked on every circuit call.
29//! If the owner unstakes below the threshold, all circuits revert.
30
31use 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
44// ---------------------------------------------------------------------------
45// Constants
46// ---------------------------------------------------------------------------
47
48/// 100,000 TRTH in base units (ONE_TRTH = 1_000_000_000)
49pub 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;
56/// nonce(12) + ct(16) + tag(16) = 44 bytes
57pub const CIPHERTEXT_LEN: usize = 44;
58
59// ---------------------------------------------------------------------------
60// Extended McpStateView - adds staking access
61// ---------------------------------------------------------------------------
62
63/// Extended view trait that includes staking state.
64/// Implement this alongside McpStateView for private balance circuits.
65pub trait PrivateBalanceStateView: McpStateView {
66    fn staking(&self) -> &StakingState;
67}
68
69// ---------------------------------------------------------------------------
70// Storage keys
71// ---------------------------------------------------------------------------
72
73pub mod pb_keys {
74    use crate::registry_keys::blake3_key;
75
76    /// Commitment: blake3(balance_le16 || nonce_le16 || ct_hash_lo16)
77    pub const COMMITMENT: [u8; 32] = key(b"pb:c");
78    /// AES-GCM ciphertext bytes 0..32
79    pub const CIPHER_LO: [u8; 32] = key(b"pb:l");
80    /// AES-GCM ciphertext bytes 32..44 (12 bytes used)
81    pub const CIPHER_HI: [u8; 32] = key(b"pb:h");
82    /// Commitment nonce (u128 LE in slot[0..16])
83    pub const COMMIT_NONCE: [u8; 32] = key(b"pb:n");
84    /// Owner AccountId
85    pub const OWNER: [u8; 32] = key(b"pb:o");
86    /// Agent AccountId
87    pub const AGENT: [u8; 32] = key(b"pb:a");
88    /// Locked flag: 1 = initialized
89    pub const LOCKED: [u8; 32] = key(b"pb:L");
90    /// Stake verified flag: 1 = stake was sufficient at init
91    pub const STAKE_VERIFIED: [u8; 32] = key(b"pb:sv");
92    /// Total deposited (u128 LE, monotonic)
93    pub const TOTAL_DEPOSITED: [u8; 32] = key(b"pb:td");
94    /// Total withdrawn (u128 LE, monotonic)
95    pub const TOTAL_WITHDRAWN: [u8; 32] = key(b"pb:tw");
96    /// Total fees deducted (u128 LE, monotonic)
97    pub const TOTAL_FEES: [u8; 32] = key(b"pb:tf");
98    /// Total confidential transfers out (u128 LE, monotonic)
99    pub const TOTAL_CT_OUT: [u8; 32] = key(b"pb:to");
100
101    /// Canonical cell address for an agent
102    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
117// ---------------------------------------------------------------------------
118// Commitment (now binds ciphertext too)
119// ---------------------------------------------------------------------------
120
121/// Commitment = Rescue(balance || nonce || ct_hash_lo16), serialized as 32 bytes.
122///
123/// Private-balance state commitments intentionally use the same Rescue-Prime
124/// digest format as the confidential-transfer STARK. That keeps init, deposit,
125/// withdraw, and confidential transfer on one commitment scheme and avoids a
126/// proof path that can never match cells created by the normal CLI flow.
127pub 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
148// ---------------------------------------------------------------------------
149// Encryption helpers
150// ---------------------------------------------------------------------------
151
152pub 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
213// ---------------------------------------------------------------------------
214// Storage packing
215// ---------------------------------------------------------------------------
216
217fn 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
261// ---------------------------------------------------------------------------
262// Range checks
263// ---------------------------------------------------------------------------
264
265fn 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
299// ---------------------------------------------------------------------------
300// Staking gate
301// ---------------------------------------------------------------------------
302
303/// Check that the owner has >= PRIVATE_BALANCE_MIN_STAKE staked.
304/// Called at the top of every circuit.
305fn 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
320/// Resolve owner pubkey bytes from AccountRecord for stake lookup.
321fn 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
332// ---------------------------------------------------------------------------
333// Common cell guards (used by all circuits after init)
334// ---------------------------------------------------------------------------
335
336struct 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// ---------------------------------------------------------------------------
417// Intent enum
418// ---------------------------------------------------------------------------
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub enum PrivateBalanceIntent {
422    /// Owner deploys private balance cell. Requires 100k TRTH staked.
423    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 public TRTH into private balance.
432    /// Amount is revealed (it's a public debit). Balance before/after hidden.
433    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 from private balance to public on-chain balance.
444    /// Amount is revealed. Balance before/after hidden.
445    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    /// Confidential transfer: amount and private balances stay hidden.
457    ///
458    /// The submitted STARK proof binds sender, recipient, and amount commitments
459    /// while proving the private balance conservation equations inside the AIR.
460    /// The recipient receives a new encrypted balance and commitment without
461    /// revealing the transferred amount on-chain.
462    ConfidentialTransfer {
463        sender_cell_id: AccountId,
464        sender_agent_id: AccountId,
465        recipient_cell_id: AccountId,
466        /// Commitment to the transfer amount (hides the amount)
467        amount_commitment: [u8; 32],
468        /// Winterfell STARK proof bytes
469        stark_proof: Vec<u8>,
470        /// Sender's new encrypted balance
471        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's new encrypted balance
476        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    /// Fee deduction by fee authority only.
483    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
495// ---------------------------------------------------------------------------
496// ZK circuits moved to zk_transfer module
497use crate::zk_transfer::{bytes_to_digest, verify_ct_proof, CtPublicInputs};
498
499// ---------------------------------------------------------------------------
500// Circuit 1: InitPrivateBalance
501// ---------------------------------------------------------------------------
502
503pub 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    // --- Staking gate ---
527    let pubkey = owner_pubkey(state, &sender)?;
528    check_stake_gate(state.staking(), &pubkey)?;
529
530    // --- Guards ---
531    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
638// ---------------------------------------------------------------------------
639// Circuit 2: Deposit
640// ---------------------------------------------------------------------------
641
642pub 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    // The owner stakes for the private balance cell; the authorized agent may submit withdrawals.
672    let pubkey = owner_pubkey(state, &g.stored_owner)?;
673    check_stake_gate(state.staking(), &pubkey)?;
674
675    // Only owner can deposit (it's a public debit from their account)
676    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    // Debit sender's on-chain balance
708    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
733// ---------------------------------------------------------------------------
734// Circuit 3: Withdraw
735// ---------------------------------------------------------------------------
736
737pub 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    // The owner stakes for the private balance cell; the authorized agent may submit withdrawals.
770    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    // Credit recipient on-chain
805    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
827// ---------------------------------------------------------------------------
828// Circuit 4: ConfidentialTransfer (ZK)
829// ---------------------------------------------------------------------------
830
831pub 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    // --- Load and guard sender cell ---
885    let sg = load_and_guard(state, sender_cell_id, sender_agent_id, &sender, s_old_comm)?;
886
887    // The owner stakes for the private balance cell; the authorized agent may relay the transfer.
888    let pubkey = owner_pubkey(state, &sg.stored_owner)?;
889    check_stake_gate(state.staking(), &pubkey)?;
890
891    // --- Load recipient cell ---
892    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    // --- Sender != recipient ---
918    if sender_cell_id == recipient_cell_id {
919        return Err("Sender and recipient cells must be different".into());
920    }
921
922    // --- Nonce checks ---
923    check_new_nonce(s_new_nonce, sg.old_nonce)?;
924    check_new_nonce(r_new_nonce, r_old_nonce)?;
925
926    // --- Commitment non-zero ---
927    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    // --- Ciphertext lengths ---
938    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    // --- Proof size sanity (prevent DoS via huge proof) ---
950    const MAX_PROOF_BYTES: usize = 512 * 1024; // 512 KB
951    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    // --- Verify STARK proof (full Rescue-Prime AIR) ---
962    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    // --- Update TOTAL_CT_OUT (monotonic) ---
972    // We do not know the amount (it's hidden), so we increment by 1 (tx count).
973    // The actual amount is proven correct by the ZK proof.
974    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    // Update sender cell
983    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    // Update recipient cell
997    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
1013// ---------------------------------------------------------------------------
1014// Circuit 5: FeeDeduct
1015// ---------------------------------------------------------------------------
1016
1017pub 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    // Fee authority check
1048    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
1112// ---------------------------------------------------------------------------
1113// Conflict domain
1114// ---------------------------------------------------------------------------
1115
1116pub 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// ---------------------------------------------------------------------------
1182// Tests
1183// ---------------------------------------------------------------------------
1184
1185#[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(), // pubkey = id bytes for test simplicity
1241                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        // pubkey_bytes = owner id bytes (set in add_account)
1251        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    // --- Staking gate ---
1297
1298    #[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        // no stake_owner call
1306
1307        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        // Remove stake
1327        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    // --- Init guards ---
1343
1344    #[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    // --- Deposit ---
1397
1398    #[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], // wrong
1435        };
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        // drain owner balance
1448        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    // --- Withdraw ---
1488
1489    #[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(), // same as init nonce
1507            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    // --- Encryption round-trip ---
1539
1540    #[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    // --- Commitment binding ---
1559
1560    #[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    // --- Range checks ---
1587
1588    #[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}