1use crate::error::{Result, TokenError};
35use dashmap::DashMap;
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38use std::sync::Arc;
39use tenzro_storage::{KvStore, WriteOp, CF_AGENTS};
40use tenzro_types::primitives::{Address, Timestamp};
41use tracing::{debug, info, warn};
42
43const BOND_VAULT_DOMAIN: &[u8] = b"tenzro/agent-bond/vault";
45const INSURANCE_VAULT_DOMAIN: &[u8] = b"tenzro/insurance-pool/vault";
47
48pub const BOND_KEY_PREFIX: &[u8] = b"bond:";
50pub const BOND_BY_CONTROLLER_PREFIX: &[u8] = b"bond_by_controller:";
52pub const CLAIM_KEY_PREFIX: &[u8] = b"insurance_claim:";
54pub const INSURANCE_POOL_KEY: &[u8] = b"insurance_pool:singleton";
56
57pub const DEFAULT_COOLDOWN_MS: i64 = 14 * 24 * 60 * 60 * 1000;
59pub const DEFAULT_MIN_RESIDUAL: u128 = 10 * 1_000_000_000_000_000_000;
61pub const DEFAULT_MAX_SINGLE_SLASH_BPS: u16 = 5000;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "PascalCase")]
67pub enum BondLifecycle {
68 Active,
70 Cooldown,
73 Frozen,
75 Slashed,
77 Returned,
81}
82
83impl BondLifecycle {
84 pub fn as_str(self) -> &'static str {
85 match self {
86 BondLifecycle::Active => "Active",
87 BondLifecycle::Cooldown => "Cooldown",
88 BondLifecycle::Frozen => "Frozen",
89 BondLifecycle::Slashed => "Slashed",
90 BondLifecycle::Returned => "Returned",
91 }
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(tag = "kind", rename_all = "snake_case")]
99pub enum BondEvent {
100 Posted { amount: u128, at: Timestamp },
101 Increased { amount: u128, at: Timestamp },
102 WithdrawInitiated { cooldown_until: Timestamp, at: Timestamp },
103 Frozen { reason: String, at: Timestamp },
104 Slashed { amount: u128, recipient_kind: String, claim_id: Option<String>, at: Timestamp },
105 Returned { at: Timestamp },
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct AgentBondState {
112 pub agent_did: String,
113 pub controller_did: String,
114 pub amount: u128,
116 pub state: BondLifecycle,
117 pub cooldown_until: Option<Timestamp>,
120 pub last_modified_block: u64,
123 pub history: Vec<BondEvent>,
124}
125
126impl AgentBondState {
127 pub fn is_promotion_eligible(&self) -> bool {
129 matches!(self.state, BondLifecycle::Active)
130 }
131
132 pub fn effective_for_promotion(&self) -> u128 {
134 if self.is_promotion_eligible() {
135 self.amount
136 } else {
137 0
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "PascalCase")]
145pub enum ClaimStatus {
146 Open,
148 Approved,
150 Rejected,
152 Paid,
154}
155
156impl ClaimStatus {
157 pub fn as_str(self) -> &'static str {
158 match self {
159 ClaimStatus::Open => "Open",
160 ClaimStatus::Approved => "Approved",
161 ClaimStatus::Rejected => "Rejected",
162 ClaimStatus::Paid => "Paid",
163 }
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ClaimRecord {
171 pub claim_id: String,
174 pub claimant_did: String,
175 pub against_agent_did: String,
176 pub amount_requested: u128,
177 pub receipt_refs: Vec<String>,
179 pub narrative: Option<String>,
182 pub status: ClaimStatus,
183 pub governance_ref: Option<String>,
185 pub paid_amount: Option<u128>,
186 pub filed_at: Timestamp,
187 pub claimant_address: Address,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, Default)]
194pub struct InsurancePoolState {
195 pub balance_wei: u128,
196 pub paid_claims: u64,
197 pub total_paid_wei: u128,
198 pub open_claim_count: u64,
199}
200
201pub fn derive_bond_vault_address(agent_did: &str) -> Address {
206 let mut hasher = Sha256::new();
207 hasher.update(BOND_VAULT_DOMAIN);
208 hasher.update(agent_did.as_bytes());
209 let digest = hasher.finalize();
210 let mut bytes = [0u8; 32];
211 bytes.copy_from_slice(&digest);
212 Address::new(bytes)
213}
214
215pub fn derive_insurance_pool_address() -> Address {
217 let mut hasher = Sha256::new();
218 hasher.update(INSURANCE_VAULT_DOMAIN);
219 let digest = hasher.finalize();
220 let mut bytes = [0u8; 32];
221 bytes.copy_from_slice(&digest);
222 Address::new(bytes)
223}
224
225pub fn derive_claim_id(claimant_did: &str, against_agent_did: &str, nonce_le: u64) -> [u8; 32] {
227 let mut hasher = Sha256::new();
228 hasher.update(b"tenzro/insurance-claim/id");
229 hasher.update(claimant_did.as_bytes());
230 hasher.update(against_agent_did.as_bytes());
231 hasher.update(nonce_le.to_le_bytes());
232 let digest = hasher.finalize();
233 let mut bytes = [0u8; 32];
234 bytes.copy_from_slice(&digest);
235 bytes
236}
237
238pub struct BondManager {
245 bonds: DashMap<String, AgentBondState>,
247 by_controller: DashMap<String, Vec<String>>,
249 claims: DashMap<String, ClaimRecord>,
251 pool: parking_lot::RwLock<InsurancePoolState>,
253 storage: Option<Arc<dyn KvStore>>,
256 cooldown_ms: i64,
258 min_residual: u128,
260 max_single_slash_bps: u16,
262}
263
264impl Default for BondManager {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270impl BondManager {
271 pub fn new() -> Self {
274 Self {
275 bonds: DashMap::new(),
276 by_controller: DashMap::new(),
277 claims: DashMap::new(),
278 pool: parking_lot::RwLock::new(InsurancePoolState::default()),
279 storage: None,
280 cooldown_ms: DEFAULT_COOLDOWN_MS,
281 min_residual: DEFAULT_MIN_RESIDUAL,
282 max_single_slash_bps: DEFAULT_MAX_SINGLE_SLASH_BPS,
283 }
284 }
285
286 pub fn with_storage(storage: Arc<dyn KvStore>) -> Result<Self> {
290 let mgr = Self {
291 bonds: DashMap::new(),
292 by_controller: DashMap::new(),
293 claims: DashMap::new(),
294 pool: parking_lot::RwLock::new(InsurancePoolState::default()),
295 storage: Some(storage.clone()),
296 cooldown_ms: DEFAULT_COOLDOWN_MS,
297 min_residual: DEFAULT_MIN_RESIDUAL,
298 max_single_slash_bps: DEFAULT_MAX_SINGLE_SLASH_BPS,
299 };
300 mgr.hydrate_from_storage()?;
301 Ok(mgr)
302 }
303
304 pub fn with_governance(
307 mut self,
308 cooldown_ms: i64,
309 min_residual: u128,
310 max_single_slash_bps: u16,
311 ) -> Self {
312 self.cooldown_ms = cooldown_ms;
313 self.min_residual = min_residual;
314 self.max_single_slash_bps = max_single_slash_bps;
315 self
316 }
317
318 fn hydrate_from_storage(&self) -> Result<()> {
319 let storage = match &self.storage {
320 Some(s) => s.clone(),
321 None => return Ok(()),
322 };
323
324 let mut bond_count = 0usize;
325 let bond_rows = storage
326 .scan_prefix(CF_AGENTS, BOND_KEY_PREFIX)
327 .map_err(|e| TokenError::StorageError(format!("scan bonds: {}", e)))?;
328 for (_key, value) in bond_rows {
329 let bond: AgentBondState = serde_json::from_slice(&value)
330 .map_err(|e| TokenError::StorageError(format!("decode bond: {}", e)))?;
331 self.by_controller
335 .entry(bond.controller_did.clone())
336 .or_default()
337 .push(bond.agent_did.clone());
338 self.bonds.insert(bond.agent_did.clone(), bond);
339 bond_count += 1;
340 }
341
342 let mut claim_count = 0usize;
343 let claim_rows = storage
344 .scan_prefix(CF_AGENTS, CLAIM_KEY_PREFIX)
345 .map_err(|e| TokenError::StorageError(format!("scan claims: {}", e)))?;
346 for (_key, value) in claim_rows {
347 let claim: ClaimRecord = serde_json::from_slice(&value)
348 .map_err(|e| TokenError::StorageError(format!("decode claim: {}", e)))?;
349 self.claims.insert(claim.claim_id.clone(), claim);
350 claim_count += 1;
351 }
352
353 if let Some(value) = storage
354 .get(CF_AGENTS, INSURANCE_POOL_KEY)
355 .map_err(|e| TokenError::StorageError(format!("get pool: {}", e)))?
356 {
357 let pool: InsurancePoolState = serde_json::from_slice(&value)
358 .map_err(|e| TokenError::StorageError(format!("decode pool: {}", e)))?;
359 *self.pool.write() = pool;
360 }
361
362 info!(
363 bond_count,
364 claim_count,
365 pool_balance = self.pool.read().balance_wei,
366 "BondManager hydrated from storage"
367 );
368 Ok(())
369 }
370
371 fn persist_bond(&self, bond: &AgentBondState) -> Result<()> {
372 if let Some(storage) = &self.storage {
373 let mut key = BOND_KEY_PREFIX.to_vec();
374 key.extend_from_slice(bond.agent_did.as_bytes());
375 let value = serde_json::to_vec(bond).map_err(|e| {
376 TokenError::StorageError(format!("encode bond: {}", e))
377 })?;
378 storage
379 .write_batch_sync(vec![WriteOp::Put {
380 cf: CF_AGENTS.to_string(),
381 key,
382 value,
383 }])
384 .map_err(|e| TokenError::StorageError(format!("persist bond: {}", e)))?;
385 }
386 Ok(())
387 }
388
389 fn persist_claim(&self, claim: &ClaimRecord) -> Result<()> {
390 if let Some(storage) = &self.storage {
391 let mut key = CLAIM_KEY_PREFIX.to_vec();
392 key.extend_from_slice(claim.claim_id.as_bytes());
393 let value = serde_json::to_vec(claim).map_err(|e| {
394 TokenError::StorageError(format!("encode claim: {}", e))
395 })?;
396 storage
397 .write_batch_sync(vec![WriteOp::Put {
398 cf: CF_AGENTS.to_string(),
399 key,
400 value,
401 }])
402 .map_err(|e| TokenError::StorageError(format!("persist claim: {}", e)))?;
403 }
404 Ok(())
405 }
406
407 fn persist_pool(&self, pool: &InsurancePoolState) -> Result<()> {
408 if let Some(storage) = &self.storage {
409 let value = serde_json::to_vec(pool).map_err(|e| {
410 TokenError::StorageError(format!("encode pool: {}", e))
411 })?;
412 storage
413 .write_batch_sync(vec![WriteOp::Put {
414 cf: CF_AGENTS.to_string(),
415 key: INSURANCE_POOL_KEY.to_vec(),
416 value,
417 }])
418 .map_err(|e| TokenError::StorageError(format!("persist pool: {}", e)))?;
419 }
420 Ok(())
421 }
422
423 pub fn post(
434 &self,
435 agent_did: &str,
436 controller_did: &str,
437 amount: u128,
438 block_height: u64,
439 ) -> Result<AgentBondState> {
440 if amount == 0 {
441 return Err(TokenError::InvalidParameter(
442 "bond amount must be greater than zero".to_string(),
443 ));
444 }
445 if let Some(existing) = self.bonds.get(agent_did) {
446 return Err(TokenError::InvalidParameter(format!(
447 "agent {} already has a bond (state={})",
448 agent_did,
449 existing.state.as_str()
450 )));
451 }
452 let now = Timestamp::now();
453 let state = AgentBondState {
454 agent_did: agent_did.to_string(),
455 controller_did: controller_did.to_string(),
456 amount,
457 state: BondLifecycle::Active,
458 cooldown_until: None,
459 last_modified_block: block_height,
460 history: vec![BondEvent::Posted { amount, at: now }],
461 };
462 self.persist_bond(&state)?;
463 self.bonds.insert(agent_did.to_string(), state.clone());
464 self.by_controller
465 .entry(controller_did.to_string())
466 .or_default()
467 .push(agent_did.to_string());
468 debug!(agent = %agent_did, controller = %controller_did, amount, "AgentBond posted");
469 Ok(state)
470 }
471
472 pub fn increase(
476 &self,
477 agent_did: &str,
478 amount: u128,
479 block_height: u64,
480 ) -> Result<AgentBondState> {
481 if amount == 0 {
482 return Err(TokenError::InvalidParameter(
483 "increase amount must be greater than zero".to_string(),
484 ));
485 }
486 let mut bond_ref = self
487 .bonds
488 .get_mut(agent_did)
489 .ok_or_else(|| TokenError::NotFound(format!("no bond for agent {}", agent_did)))?;
490 if bond_ref.state != BondLifecycle::Active {
491 return Err(TokenError::InvalidParameter(format!(
492 "cannot increase bond in {} state",
493 bond_ref.state.as_str()
494 )));
495 }
496 bond_ref.amount = bond_ref.amount.checked_add(amount).ok_or_else(|| {
497 TokenError::InvalidParameter("bond amount overflow".to_string())
498 })?;
499 bond_ref.last_modified_block = block_height;
500 bond_ref.history.push(BondEvent::Increased {
501 amount,
502 at: Timestamp::now(),
503 });
504 let snapshot = bond_ref.clone();
505 drop(bond_ref);
506 self.persist_bond(&snapshot)?;
507 debug!(agent = %agent_did, amount, total = snapshot.amount, "AgentBond increased");
508 Ok(snapshot)
509 }
510
511 pub fn withdraw(
516 &self,
517 agent_did: &str,
518 block_height: u64,
519 ) -> Result<AgentBondState> {
520 let mut bond_ref = self
521 .bonds
522 .get_mut(agent_did)
523 .ok_or_else(|| TokenError::NotFound(format!("no bond for agent {}", agent_did)))?;
524 if bond_ref.state != BondLifecycle::Active {
525 return Err(TokenError::InvalidParameter(format!(
526 "cannot withdraw bond in {} state",
527 bond_ref.state.as_str()
528 )));
529 }
530 let now = Timestamp::now();
531 let cooldown_until = Timestamp::new(now.0.saturating_add(self.cooldown_ms));
532 bond_ref.state = BondLifecycle::Cooldown;
533 bond_ref.cooldown_until = Some(cooldown_until);
534 bond_ref.last_modified_block = block_height;
535 bond_ref.history.push(BondEvent::WithdrawInitiated {
536 cooldown_until,
537 at: now,
538 });
539 let snapshot = bond_ref.clone();
540 drop(bond_ref);
541 self.persist_bond(&snapshot)?;
542 info!(agent = %agent_did, cooldown_until = ?cooldown_until, "AgentBond withdrawal initiated");
543 Ok(snapshot)
544 }
545
546 pub fn finalize_withdrawal(
550 &self,
551 agent_did: &str,
552 block_height: u64,
553 ) -> Result<AgentBondState> {
554 let mut bond_ref = self
555 .bonds
556 .get_mut(agent_did)
557 .ok_or_else(|| TokenError::NotFound(format!("no bond for agent {}", agent_did)))?;
558 if bond_ref.state != BondLifecycle::Cooldown {
559 return Err(TokenError::InvalidParameter(format!(
560 "cannot finalize withdrawal: bond is in {} state",
561 bond_ref.state.as_str()
562 )));
563 }
564 let cooldown_until = bond_ref.cooldown_until.ok_or_else(|| {
565 TokenError::InvalidParameter("cooldown bond missing cooldown_until".to_string())
566 })?;
567 if Timestamp::now() < cooldown_until {
568 return Err(TokenError::InvalidParameter(format!(
569 "cooldown not yet elapsed (until {:?})",
570 cooldown_until
571 )));
572 }
573 bond_ref.state = BondLifecycle::Returned;
574 bond_ref.amount = 0;
575 bond_ref.last_modified_block = block_height;
576 bond_ref.history.push(BondEvent::Returned { at: Timestamp::now() });
577 let snapshot = bond_ref.clone();
578 drop(bond_ref);
579 self.persist_bond(&snapshot)?;
580 info!(agent = %agent_did, "AgentBond returned to controller");
581 Ok(snapshot)
582 }
583
584 pub fn freeze(
588 &self,
589 agent_did: &str,
590 reason: &str,
591 block_height: u64,
592 ) -> Result<AgentBondState> {
593 let mut bond_ref = self
594 .bonds
595 .get_mut(agent_did)
596 .ok_or_else(|| TokenError::NotFound(format!("no bond for agent {}", agent_did)))?;
597 if !matches!(
598 bond_ref.state,
599 BondLifecycle::Active | BondLifecycle::Cooldown
600 ) {
601 return Err(TokenError::InvalidParameter(format!(
602 "cannot freeze bond in {} state",
603 bond_ref.state.as_str()
604 )));
605 }
606 bond_ref.state = BondLifecycle::Frozen;
607 bond_ref.last_modified_block = block_height;
608 bond_ref.history.push(BondEvent::Frozen {
609 reason: reason.to_string(),
610 at: Timestamp::now(),
611 });
612 let snapshot = bond_ref.clone();
613 drop(bond_ref);
614 self.persist_bond(&snapshot)?;
615 warn!(agent = %agent_did, reason, "AgentBond frozen by Quarantine");
616 Ok(snapshot)
617 }
618
619 pub fn unfreeze(&self, agent_did: &str, block_height: u64) -> Result<AgentBondState> {
623 let mut bond_ref = self
624 .bonds
625 .get_mut(agent_did)
626 .ok_or_else(|| TokenError::NotFound(format!("no bond for agent {}", agent_did)))?;
627 if bond_ref.state != BondLifecycle::Frozen {
628 return Err(TokenError::InvalidParameter(format!(
629 "cannot unfreeze bond in {} state",
630 bond_ref.state.as_str()
631 )));
632 }
633 bond_ref.state = BondLifecycle::Active;
634 bond_ref.cooldown_until = None;
635 bond_ref.last_modified_block = block_height;
636 let snapshot = bond_ref.clone();
637 drop(bond_ref);
638 self.persist_bond(&snapshot)?;
639 info!(agent = %agent_did, "AgentBond unfrozen");
640 Ok(snapshot)
641 }
642
643 pub fn slash(
654 &self,
655 agent_did: &str,
656 bps: u16,
657 claim_id: Option<String>,
658 recipient_kind: &str,
659 block_height: u64,
660 ) -> Result<(u128, AgentBondState)> {
661 if bps == 0 {
662 return Err(TokenError::InvalidParameter(
663 "slash bps must be > 0".to_string(),
664 ));
665 }
666 if bps > self.max_single_slash_bps {
667 return Err(TokenError::InvalidParameter(format!(
668 "slash bps {} exceeds max_single_slash_bps {}",
669 bps, self.max_single_slash_bps
670 )));
671 }
672 let mut bond_ref = self
673 .bonds
674 .get_mut(agent_did)
675 .ok_or_else(|| TokenError::NotFound(format!("no bond for agent {}", agent_did)))?;
676 if matches!(
677 bond_ref.state,
678 BondLifecycle::Slashed | BondLifecycle::Returned
679 ) {
680 return Err(TokenError::InvalidParameter(format!(
681 "cannot slash bond in {} terminal state",
682 bond_ref.state.as_str()
683 )));
684 }
685 let slashed = (bond_ref.amount / 10_000)
687 .saturating_mul(bps as u128)
688 .saturating_add(((bond_ref.amount % 10_000) * bps as u128) / 10_000);
689 let remainder = bond_ref.amount.saturating_sub(slashed);
690 let (final_slashed, terminal) = if remainder < self.min_residual {
692 (bond_ref.amount, true)
693 } else {
694 (slashed, false)
695 };
696 bond_ref.amount = bond_ref.amount.saturating_sub(final_slashed);
697 if terminal {
698 bond_ref.state = BondLifecycle::Slashed;
699 }
700 bond_ref.last_modified_block = block_height;
701 bond_ref.history.push(BondEvent::Slashed {
702 amount: final_slashed,
703 recipient_kind: recipient_kind.to_string(),
704 claim_id,
705 at: Timestamp::now(),
706 });
707 let snapshot = bond_ref.clone();
708 drop(bond_ref);
709 self.persist_bond(&snapshot)?;
710
711 {
713 let mut pool = self.pool.write();
714 pool.balance_wei = pool.balance_wei.saturating_add(final_slashed);
715 self.persist_pool(&pool)?;
716 }
717
718 warn!(
719 agent = %agent_did,
720 slashed = final_slashed,
721 terminal,
722 recipient = recipient_kind,
723 "AgentBond slashed → InsurancePool credited"
724 );
725 Ok((final_slashed, snapshot))
726 }
727
728 pub fn get(&self, agent_did: &str) -> Option<AgentBondState> {
731 self.bonds.get(agent_did).map(|r| r.clone())
732 }
733
734 pub fn list_by_controller(&self, controller_did: &str) -> Vec<AgentBondState> {
735 let agent_dids = match self.by_controller.get(controller_did) {
736 Some(r) => r.clone(),
737 None => return Vec::new(),
738 };
739 agent_dids
740 .into_iter()
741 .filter_map(|did| self.bonds.get(&did).map(|r| r.clone()))
742 .collect()
743 }
744
745 pub fn aggregate_bond(&self, controller_did: &str) -> u128 {
748 self.list_by_controller(controller_did)
749 .iter()
750 .filter(|b| b.is_promotion_eligible())
751 .map(|b| b.amount)
752 .fold(0u128, |acc, a| acc.saturating_add(a))
753 }
754
755 pub fn effective_bond_for_promotion(&self, agent_did: &str) -> u128 {
758 self.bonds
759 .get(agent_did)
760 .map(|r| r.effective_for_promotion())
761 .unwrap_or(0)
762 }
763
764 pub fn has_active_bond_at_least(&self, agent_did: &str, threshold: u128) -> bool {
767 self.effective_bond_for_promotion(agent_did) >= threshold
768 }
769
770 pub fn file_claim(
775 &self,
776 claimant_did: &str,
777 claimant_address: Address,
778 against_agent_did: &str,
779 amount_requested: u128,
780 receipt_refs: Vec<String>,
781 narrative: Option<String>,
782 nonce_le: u64,
783 ) -> Result<ClaimRecord> {
784 if amount_requested == 0 {
785 return Err(TokenError::InvalidParameter(
786 "claim amount must be > 0".to_string(),
787 ));
788 }
789 let claim_id = derive_claim_id(claimant_did, against_agent_did, nonce_le);
790 let claim_id_hex = hex::encode(claim_id);
791 if self.claims.contains_key(&claim_id_hex) {
792 return Err(TokenError::InvalidParameter(format!(
793 "claim {} already exists",
794 claim_id_hex
795 )));
796 }
797 let record = ClaimRecord {
798 claim_id: claim_id_hex.clone(),
799 claimant_did: claimant_did.to_string(),
800 against_agent_did: against_agent_did.to_string(),
801 amount_requested,
802 receipt_refs,
803 narrative,
804 status: ClaimStatus::Open,
805 governance_ref: None,
806 paid_amount: None,
807 filed_at: Timestamp::now(),
808 claimant_address,
809 };
810 self.persist_claim(&record)?;
811 self.claims.insert(claim_id_hex.clone(), record.clone());
812 {
813 let mut pool = self.pool.write();
814 pool.open_claim_count = pool.open_claim_count.saturating_add(1);
815 self.persist_pool(&pool)?;
816 }
817 info!(
818 claim = %claim_id_hex,
819 claimant = %claimant_did,
820 against = %against_agent_did,
821 amount_requested,
822 "Insurance claim filed"
823 );
824 Ok(record)
825 }
826
827 pub fn approve_claim(
831 &self,
832 claim_id_hex: &str,
833 approved_amount: u128,
834 governance_ref: String,
835 ) -> Result<ClaimRecord> {
836 let mut claim_ref = self
837 .claims
838 .get_mut(claim_id_hex)
839 .ok_or_else(|| TokenError::NotFound(format!("claim {} not found", claim_id_hex)))?;
840 if claim_ref.status != ClaimStatus::Open {
841 return Err(TokenError::InvalidParameter(format!(
842 "cannot approve claim in {} state",
843 claim_ref.status.as_str()
844 )));
845 }
846 if approved_amount == 0 || approved_amount > claim_ref.amount_requested {
847 return Err(TokenError::InvalidParameter(format!(
848 "approved_amount {} must be > 0 and <= requested {}",
849 approved_amount, claim_ref.amount_requested
850 )));
851 }
852 claim_ref.status = ClaimStatus::Approved;
853 claim_ref.paid_amount = Some(approved_amount);
854 claim_ref.governance_ref = Some(governance_ref);
855 let snapshot = claim_ref.clone();
856 drop(claim_ref);
857 self.persist_claim(&snapshot)?;
858 info!(claim = %claim_id_hex, approved_amount, "Insurance claim approved");
859 Ok(snapshot)
860 }
861
862 pub fn reject_claim(
864 &self,
865 claim_id_hex: &str,
866 governance_ref: String,
867 ) -> Result<ClaimRecord> {
868 let mut claim_ref = self
869 .claims
870 .get_mut(claim_id_hex)
871 .ok_or_else(|| TokenError::NotFound(format!("claim {} not found", claim_id_hex)))?;
872 if claim_ref.status != ClaimStatus::Open {
873 return Err(TokenError::InvalidParameter(format!(
874 "cannot reject claim in {} state",
875 claim_ref.status.as_str()
876 )));
877 }
878 claim_ref.status = ClaimStatus::Rejected;
879 claim_ref.governance_ref = Some(governance_ref);
880 let snapshot = claim_ref.clone();
881 drop(claim_ref);
882 self.persist_claim(&snapshot)?;
883 {
884 let mut pool = self.pool.write();
885 pool.open_claim_count = pool.open_claim_count.saturating_sub(1);
886 self.persist_pool(&pool)?;
887 }
888 info!(claim = %claim_id_hex, "Insurance claim rejected");
889 Ok(snapshot)
890 }
891
892 pub fn pay_claim(&self, claim_id_hex: &str) -> Result<ClaimRecord> {
900 let mut claim_ref = self
901 .claims
902 .get_mut(claim_id_hex)
903 .ok_or_else(|| TokenError::NotFound(format!("claim {} not found", claim_id_hex)))?;
904 if claim_ref.status != ClaimStatus::Approved {
905 return Err(TokenError::InvalidParameter(format!(
906 "cannot pay claim in {} state",
907 claim_ref.status.as_str()
908 )));
909 }
910 let amount = claim_ref.paid_amount.ok_or_else(|| {
911 TokenError::InvalidParameter("approved claim missing paid_amount".to_string())
912 })?;
913 let mut pool = self.pool.write();
914 if pool.balance_wei < amount {
915 return Err(TokenError::InsufficientBalance {
916 required: amount,
917 available: pool.balance_wei,
918 });
919 }
920 pool.balance_wei = pool.balance_wei.saturating_sub(amount);
921 pool.paid_claims = pool.paid_claims.saturating_add(1);
922 pool.total_paid_wei = pool.total_paid_wei.saturating_add(amount);
923 pool.open_claim_count = pool.open_claim_count.saturating_sub(1);
924 self.persist_pool(&pool)?;
925 drop(pool);
926
927 claim_ref.status = ClaimStatus::Paid;
928 let snapshot = claim_ref.clone();
929 drop(claim_ref);
930 self.persist_claim(&snapshot)?;
931 info!(claim = %claim_id_hex, amount, "Insurance claim paid");
932 Ok(snapshot)
933 }
934
935 pub fn get_claim(&self, claim_id_hex: &str) -> Option<ClaimRecord> {
936 self.claims.get(claim_id_hex).map(|r| r.clone())
937 }
938
939 pub fn list_claims(&self) -> Vec<ClaimRecord> {
940 self.claims.iter().map(|r| r.value().clone()).collect()
941 }
942
943 pub fn pool_state(&self) -> InsurancePoolState {
944 self.pool.read().clone()
945 }
946}
947
948impl tenzro_types::principal_chain::BondLookup for BondManager {
963 fn actor_bond(&self, did: &str) -> Option<u128> {
964 let amount = self.effective_bond_for_promotion(did);
965 if amount == 0 {
966 None
967 } else {
968 Some(amount)
969 }
970 }
971
972 fn controller_aggregate(&self, controller_did: &str) -> Option<u128> {
973 let total = self.aggregate_bond(controller_did);
974 if total == 0 {
975 None
976 } else {
977 Some(total)
978 }
979 }
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985
986 fn addr(byte: u8) -> Address {
987 Address::new([byte; 32])
988 }
989
990 #[test]
991 fn post_creates_active_bond() {
992 let m = BondManager::new();
993 let bond = m
994 .post("did:tenzro:machine:a", "did:tenzro:human:c", 5_000, 100)
995 .unwrap();
996 assert_eq!(bond.state, BondLifecycle::Active);
997 assert_eq!(bond.amount, 5_000);
998 assert_eq!(m.effective_bond_for_promotion("did:tenzro:machine:a"), 5_000);
999 }
1000
1001 #[test]
1002 fn double_post_rejects() {
1003 let m = BondManager::new();
1004 m.post("a", "c", 100, 1).unwrap();
1005 assert!(m.post("a", "c", 100, 2).is_err());
1006 }
1007
1008 #[test]
1009 fn increase_only_active() {
1010 let m = BondManager::new();
1011 m.post("a", "c", 100, 1).unwrap();
1012 m.increase("a", 50, 2).unwrap();
1013 assert_eq!(m.get("a").unwrap().amount, 150);
1014 m.withdraw("a", 3).unwrap();
1015 assert!(m.increase("a", 50, 4).is_err());
1016 }
1017
1018 #[test]
1019 fn withdraw_demotes_lane() {
1020 let m = BondManager::new();
1021 m.post("a", "c", 100, 1).unwrap();
1022 assert_eq!(m.effective_bond_for_promotion("a"), 100);
1023 m.withdraw("a", 2).unwrap();
1024 assert_eq!(m.effective_bond_for_promotion("a"), 0);
1026 }
1027
1028 #[test]
1029 fn freeze_blocks_withdrawal() {
1030 let m = BondManager::new();
1031 m.post("a", "c", 100, 1).unwrap();
1032 m.freeze("a", "quarantine_test", 2).unwrap();
1033 assert!(m.withdraw("a", 3).is_err());
1034 assert_eq!(m.get("a").unwrap().state, BondLifecycle::Frozen);
1035 }
1036
1037 #[test]
1038 fn slash_credits_pool_and_demotes() {
1039 let m = BondManager::new();
1040 m.post("a", "c", 1_000_000_000_000_000_000_000u128, 1).unwrap(); let (slashed, bond) = m.slash("a", 2500, None, "terminate", 2).unwrap();
1042 assert_eq!(slashed, 250_000_000_000_000_000_000u128);
1044 assert_eq!(bond.amount, 750_000_000_000_000_000_000u128);
1045 assert_eq!(m.pool_state().balance_wei, 250_000_000_000_000_000_000u128);
1046 assert_eq!(bond.state, BondLifecycle::Active);
1047 }
1048
1049 #[test]
1050 fn slash_below_residual_drains_fully() {
1051 let m = BondManager::new();
1052 m.post("a", "c", 12 * 1_000_000_000_000_000_000u128, 1).unwrap();
1054 let (slashed, bond) = m.slash("a", 5000, None, "terminate", 2).unwrap();
1055 assert_eq!(slashed, 12 * 1_000_000_000_000_000_000u128);
1056 assert_eq!(bond.amount, 0);
1057 assert_eq!(bond.state, BondLifecycle::Slashed);
1058 }
1059
1060 #[test]
1061 fn slash_rejects_above_max_bps() {
1062 let m = BondManager::new();
1063 m.post("a", "c", 1_000, 1).unwrap();
1064 assert!(m.slash("a", 6000, None, "terminate", 2).is_err());
1065 }
1066
1067 #[test]
1068 fn aggregate_bond_only_active() {
1069 let m = BondManager::new();
1070 m.post("a", "ctrl", 1_000, 1).unwrap();
1071 m.post("b", "ctrl", 5_000, 2).unwrap();
1072 m.post("c", "ctrl", 10_000, 3).unwrap();
1073 m.withdraw("a", 4).unwrap(); assert_eq!(m.aggregate_bond("ctrl"), 15_000);
1075 }
1076
1077 #[test]
1078 fn claim_full_flow() {
1079 let m = BondManager::new();
1080 m.post("a", "c", 100_000_000_000_000_000_000_000u128, 1).unwrap(); m.slash("a", 5000, None, "terminate", 2).unwrap();
1083 let pool_before = m.pool_state().balance_wei;
1084 assert!(pool_before >= 50_000_000_000_000_000_000_000u128);
1085
1086 let claim = m
1087 .file_claim(
1088 "did:tenzro:human:victim",
1089 addr(0xab),
1090 "did:tenzro:machine:bad",
1091 10_000_000_000_000_000_000u128, vec!["receipt:1".into()],
1093 Some("agent didn't deliver".into()),
1094 42,
1095 )
1096 .unwrap();
1097 m.approve_claim(&claim.claim_id, 5_000_000_000_000_000_000u128, "prop:99".into())
1098 .unwrap();
1099 let paid = m.pay_claim(&claim.claim_id).unwrap();
1100 assert_eq!(paid.status, ClaimStatus::Paid);
1101 assert_eq!(paid.paid_amount, Some(5_000_000_000_000_000_000u128));
1102 assert_eq!(m.pool_state().total_paid_wei, 5_000_000_000_000_000_000u128);
1103 assert_eq!(
1104 m.pool_state().balance_wei,
1105 pool_before - 5_000_000_000_000_000_000u128
1106 );
1107 }
1108
1109 #[test]
1110 fn pay_claim_rejects_when_pool_underfunded() {
1111 let m = BondManager::new();
1112 let claim = m
1113 .file_claim(
1114 "did:tenzro:human:victim",
1115 addr(0xab),
1116 "did:tenzro:machine:bad",
1117 100,
1118 vec!["receipt:1".into()],
1119 None,
1120 7,
1121 )
1122 .unwrap();
1123 m.approve_claim(&claim.claim_id, 100, "prop:1".into()).unwrap();
1124 assert!(m.pay_claim(&claim.claim_id).is_err());
1126 assert_eq!(
1128 m.get_claim(&claim.claim_id).unwrap().status,
1129 ClaimStatus::Approved
1130 );
1131 }
1132}