Skip to main content

tenzro_token/
bond.rs

1//! AgentBond surety primitive (Agent-Swarm Spec 9)
2//!
3//! Each autonomous agent identity (`did:tenzro:machine:...`) may have a
4//! bond posted *by its controller* on its behalf. The bond is held in a
5//! deterministic, key-less vault address derived per-agent. Three lifecycle
6//! operations expose it:
7//!
8//! - `post`     — controller commits TNZO to the agent's bond. Active.
9//! - `increase` — top up an Active bond.
10//! - `withdraw` — initiate cooldown; bond stays bind-able for slashing
11//!   during cooldown, returns to controller after.
12//!
13//! Plus two slashing paths:
14//!
15//! - `freeze` (Quarantine) flips Active → Frozen. Bond remains slashable
16//!   but not withdraw-able.
17//! - `slash` (Terminate or adjudicated dispute) debits the bond and
18//!   credits the InsurancePool — with a residual floor (`min_residual`)
19//!   below which the bond is fully drained and marked `Slashed`.
20//!
21//! State persists in `CF_AGENTS` under the `bond:<agent_did>` key prefix
22//! plus a `bond_by_controller:<controller_did>:<agent_did>` reverse index
23//! that lets `tenzro_listAgentBondsByController` enumerate without a
24//! full-table scan.
25//!
26//! # Lane interaction (Spec 2)
27//!
28//! `effective_bond_for_promotion(agent_did)` returns the amount eligible
29//! for lane promotion: `amount` when `state == Active`, else 0. The
30//! `lane_resolver` consults this on every admission. A bond drained by a
31//! partial slash below `bond_min_for_promotion` immediately demotes the
32//! agent — no caching, no cooldown.
33
34use 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
43/// Domain prefix for the deterministic per-agent bond vault address.
44const BOND_VAULT_DOMAIN: &[u8] = b"tenzro/agent-bond/vault";
45/// Domain prefix for the deterministic InsurancePool vault address.
46const INSURANCE_VAULT_DOMAIN: &[u8] = b"tenzro/insurance-pool/vault";
47
48/// Storage key prefix for `AgentBondState` records under `CF_AGENTS`.
49pub const BOND_KEY_PREFIX: &[u8] = b"bond:";
50/// Storage key prefix for `controller_did → agent_did` reverse index.
51pub const BOND_BY_CONTROLLER_PREFIX: &[u8] = b"bond_by_controller:";
52/// Storage key prefix for `ClaimRecord` under `CF_AGENTS`.
53pub const CLAIM_KEY_PREFIX: &[u8] = b"insurance_claim:";
54/// Singleton key for `InsurancePoolState` under `CF_AGENTS`.
55pub const INSURANCE_POOL_KEY: &[u8] = b"insurance_pool:singleton";
56
57/// Default bond cooldown: 14 days in milliseconds.
58pub const DEFAULT_COOLDOWN_MS: i64 = 14 * 24 * 60 * 60 * 1000;
59/// Default minimum residual: 10 TNZO (in 1e18 units).
60pub const DEFAULT_MIN_RESIDUAL: u128 = 10 * 1_000_000_000_000_000_000;
61/// Default maximum slash per single dispute: 50% (5000 bps).
62pub const DEFAULT_MAX_SINGLE_SLASH_BPS: u16 = 5000;
63
64/// Lifecycle of an `AgentBondState`. See agent-bond.md §"Bond lifecycle".
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "PascalCase")]
67pub enum BondLifecycle {
68    /// Posted, not Quarantined, not in cooldown — eligible for lane promotion.
69    Active,
70    /// Withdrawal initiated, cooldown timer running. Still slashable.
71    /// Demoted to Open lane during cooldown (effective_bond = 0).
72    Cooldown,
73    /// Quarantine has frozen the bond. Cannot be withdrawn; remains slashable.
74    Frozen,
75    /// Terminate (or full slash) drained the bond. Terminal state.
76    Slashed,
77    /// Cooldown elapsed without slashing; controller has reclaimed the funds.
78    /// Terminal state. The record is retained for audit; the agent's
79    /// `effective_bond_for_promotion` is 0.
80    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/// One historical event in `AgentBondState.history`. Append-only; never
96/// pruned. Retains full audit trail for downstream regulators.
97#[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/// Persistent state for one agent's bond. RocksDB row under
109/// `CF_AGENTS/bond:<agent_did>`.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct AgentBondState {
112    pub agent_did: String,
113    pub controller_did: String,
114    /// Current bond balance. Drops on `slash`; restored only by `increase`.
115    pub amount: u128,
116    pub state: BondLifecycle,
117    /// `Some(at)` once `withdraw` is called; `state` is always `Cooldown`
118    /// while this is set and not yet elapsed.
119    pub cooldown_until: Option<Timestamp>,
120    /// Block height at the most recent state-mutating operation. Useful
121    /// for reorg-safety diagnostics on operator dashboards.
122    pub last_modified_block: u64,
123    pub history: Vec<BondEvent>,
124}
125
126impl AgentBondState {
127    /// `true` if `state == Active`. Drives Spec 2 lane promotion.
128    pub fn is_promotion_eligible(&self) -> bool {
129        matches!(self.state, BondLifecycle::Active)
130    }
131
132    /// Amount eligible for lane promotion. Active → `amount`, else 0.
133    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/// Status of an `InsurancePool` claim.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "PascalCase")]
145pub enum ClaimStatus {
146    /// Filed; awaiting governance proposal adjudication.
147    Open,
148    /// Governance approved; payout queued (or already executed).
149    Approved,
150    /// Governance rejected. Terminal.
151    Rejected,
152    /// Approved AND payout executed against the pool. Terminal.
153    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/// Insurance claim filed against a bonded agent. RocksDB row under
168/// `CF_AGENTS/insurance_claim:<claim_id_hex>`.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ClaimRecord {
171    /// Hex-encoded SHA-256 commitment derived from
172    /// `(claimant_did, against_agent_did, nonce_le)`.
173    pub claim_id: String,
174    pub claimant_did: String,
175    pub against_agent_did: String,
176    pub amount_requested: u128,
177    /// On-chain receipt references (Spec 5) — chain-anchored evidence.
178    pub receipt_refs: Vec<String>,
179    /// Optional human narrative — capped at 1024 bytes by the RPC handler
180    /// before reaching this layer.
181    pub narrative: Option<String>,
182    pub status: ClaimStatus,
183    /// Governance proposal ID if one has been opened to adjudicate.
184    pub governance_ref: Option<String>,
185    pub paid_amount: Option<u128>,
186    pub filed_at: Timestamp,
187    /// Address of the claimant — used to credit `paid_amount` on Approved → Paid.
188    pub claimant_address: Address,
189}
190
191/// Top-of-pool aggregate: total balance + claim accounting. Singleton row
192/// at `CF_AGENTS/insurance_pool:singleton`.
193#[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
201/// Free helper: derive the deterministic 32-byte vault address for an
202/// agent's bond. The vault has no private key — only `BondManager` may
203/// debit/credit it via `state.set_balance` writes through
204/// [`tenzro_vm::native`].
205pub 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
215/// Free helper: deterministic InsurancePool vault address (singleton).
216pub 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
225/// Free helper: derive a deterministic claim_id from filer + target + nonce.
226pub 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
238/// Bond manager — in-memory cache + RocksDB write-through under
239/// `CF_AGENTS`. Cheap concurrent reads for the lane resolver hot path.
240///
241/// Construction options:
242/// - [`BondManager::new`] — pure in-memory (tests, light client).
243/// - [`BondManager::with_storage`] — write-through + hydrated from disk.
244pub struct BondManager {
245    /// `agent_did -> AgentBondState`
246    bonds: DashMap<String, AgentBondState>,
247    /// `controller_did -> Vec<agent_did>` reverse index.
248    by_controller: DashMap<String, Vec<String>>,
249    /// `claim_id -> ClaimRecord`
250    claims: DashMap<String, ClaimRecord>,
251    /// Singleton pool state.
252    pool: parking_lot::RwLock<InsurancePoolState>,
253    /// Optional persistence backend — when set, every mutation calls
254    /// `write_batch_sync` for fsync durability.
255    storage: Option<Arc<dyn KvStore>>,
256    /// Cooldown duration in milliseconds (governance-tunable).
257    cooldown_ms: i64,
258    /// Floor below which `slash` drains to zero and marks `Slashed`.
259    min_residual: u128,
260    /// Cap on a single slash bps; `slash` rejects above this.
261    max_single_slash_bps: u16,
262}
263
264impl Default for BondManager {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl BondManager {
271    /// Construct an in-memory manager. Use [`with_storage`] to enable
272    /// persistence + hydration.
273    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    /// Construct with RocksDB write-through and hydrate from disk.
287    /// Hydration scans `CF_AGENTS/bond:`, `CF_AGENTS/insurance_claim:`, and
288    /// the singleton `CF_AGENTS/insurance_pool:singleton`.
289    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    /// Override governance dials. Only used by `tenzro-node` startup
305    /// when a custom governance config has been loaded.
306    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            // Rebuild the controller index in-memory (we don't persist it
332            // separately because it's derivable; cheaper to re-walk than
333            // to keep two sources of truth in sync).
334            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    // ---- Bond operations ----------------------------------------------------
424
425    /// Post a bond. Caller (the VM `execute_post_agent_bond` handler)
426    /// has already debited the controller's wallet and credited the bond
427    /// vault address; this method is the bookkeeping layer that records
428    /// state for the lane resolver and history.
429    ///
430    /// Rejects:
431    /// - Existing Active/Cooldown/Frozen bond (use `increase` to top up).
432    /// - Zero amount.
433    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    /// Top up an Active bond. Cooldown/Frozen/Slashed bonds reject (use
473    /// `post` after a Returned/Slashed terminal state — only one bond
474    /// at a time per agent).
475    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    /// Initiate withdrawal. Active → Cooldown. Cooldown timer set to
512    /// `now + cooldown_ms`. Bond remains slashable for the entire cooldown.
513    /// Caller is responsible for kicking off the cooldown-elapsed callback
514    /// path (`finalize_withdrawal`) once the timer expires.
515    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    /// Mark a Cooldown bond as Returned once cooldown has elapsed. The
547    /// VM handler is responsible for crediting the controller's wallet
548    /// from the bond vault before/after this call (transactional).
549    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    /// Freeze (Quarantine integration). Active → Frozen. Cooldown bonds
585    /// are *also* frozen (cooldown timer is paused — no return until
586    /// resumed). Frozen / Slashed / Returned bonds reject.
587    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    /// Resume a frozen bond back to Active (Quarantine cleared by
620    /// governance). If a withdraw cooldown was in flight, the timer
621    /// effectively resets — callers can re-issue `withdraw` to start fresh.
622    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    /// Slash a bond. Triggered by Terminate (Spec 1) or by an approved
644    /// insurance claim (governance dispute path). Debits `bond × bps /
645    /// 10000`, credits the InsurancePool, and (if the result drops below
646    /// `min_residual`) marks the bond `Slashed` and zeroes it.
647    ///
648    /// Returns `(slashed_amount, new_bond_state)`.
649    ///
650    /// Rejects:
651    /// - `bps > max_single_slash_bps`.
652    /// - Bond in Slashed/Returned terminal states.
653    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        // Slash math — full u128 width, no precision loss for typical bond sizes.
686        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        // If remainder drops below floor, fully drain.
691        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        // Credit the InsurancePool aggregate.
712        {
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    // ---- Read accessors -----------------------------------------------------
729
730    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    /// Sum of `amount` across every Active bond owned by `controller_did`.
746    /// Used for the receipt `controller_bond_aggregate` field (Spec 5).
747    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    /// Lane-promotion query for the resolver. Returns the bond amount
756    /// if eligible (Active state), else 0.
757    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    /// `true` if the agent has an Active bond at or above `threshold`.
765    /// Hot path — called per-admission by the lane resolver.
766    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    // ---- Insurance claims ---------------------------------------------------
771
772    /// File a new insurance claim. Returns the deterministic claim_id and
773    /// inserts an `Open` claim record.
774    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    /// Approve a claim with a payout amount (governance proposal passed).
828    /// Stays in `Approved` state until the pool can fund the payout via
829    /// [`pay_claim`].
830    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    /// Reject a claim (governance vote against).
863    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    /// Execute a payout for an Approved claim. Debits the pool, marks
893    /// the claim Paid, and updates aggregates. The VM handler is
894    /// responsible for crediting the claimant's wallet from the
895    /// InsurancePool vault address.
896    ///
897    /// Rejects if pool balance is insufficient (claim stays Approved
898    /// pending refill).
899    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
948/// Bridge from `BondManager` to the principal-chain receipt envelope
949/// (Spec 5 + Spec 9). Implemented here, not in `tenzro-types`, because
950/// `BondManager` is the live source of truth and `tenzro-types` has no
951/// dependency on the token crate.
952///
953/// The mapping:
954/// - `actor_bond(did)` → `effective_for_promotion()` of the agent's
955///   `AgentBondState` when present, else `None`. Returning `None` for
956///   states like `Slashed`/`Returned` (which have `effective == 0`)
957///   keeps the receipt field clean — only meaningful, promotion-eligible
958///   bonds appear.
959/// - `controller_aggregate(did)` → `BondManager::aggregate_bond(did)`,
960///   wrapped in `Some` only when non-zero so the JSON omits the field
961///   for controllers with no Active bonds.
962impl 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        // Cooldown is not promotion-eligible.
1025        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(); // 1000 TNZO
1041        let (slashed, bond) = m.slash("a", 2500, None, "terminate", 2).unwrap();
1042        // 25% of 1000 = 250
1043        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        // Bond of 12 TNZO, residual floor is 10 TNZO. A 50% slash leaves 6 → fully drains.
1053        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(); // Cooldown — excluded
1074        assert_eq!(m.aggregate_bond("ctrl"), 15_000);
1075    }
1076
1077    #[test]
1078    fn claim_full_flow() {
1079        let m = BondManager::new();
1080        // Seed pool via slashing.
1081        m.post("a", "c", 100_000_000_000_000_000_000_000u128, 1).unwrap(); // 100k
1082        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, // 10 TNZO
1092                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        // Pool is empty — pay should fail.
1125        assert!(m.pay_claim(&claim.claim_id).is_err());
1126        // Claim stays Approved, eligible for retry once pool refills.
1127        assert_eq!(
1128            m.get_claim(&claim.claim_id).unwrap().status,
1129            ClaimStatus::Approved
1130        );
1131    }
1132}